Motia Icon
Getting Started

0.17 to 1.0 Migration Guide

Migrating from Motia v0.17.x to the Motia 1.0-RC Framework powered by iii.

This guide covers migrating from Motia v0.17.x to the Motia 1.0-RC framework. It is organized by area of concern so you can migrate incrementally.

iii is required

Motia now requires the iii engine to run. Install iii from iii.dev before proceeding with the migration. All adapter and infrastructure configuration is now done through iii via a config.yaml file -- the SDK itself no longer handles any of this.

MD Migration Guide

A Markdown version of this guide is available at motia/MIGRATION_GUIDE.md.

Configuration

Project Config

The old motia.config.ts (using defineConfig) is replaced by two files managed by iii:

ConcernOldNew
Project config & pluginsmotia.config.ts (defineConfig({...}))Removed (handled by iii engine via config.yaml)
Module/adapter configN/Aconfig.yaml (iii engine config)
Auth & hooksstreamAuth in motia.config.tsmotia.config.ts (simplified, exports only auth hooks)
Build externals.esbuildrc.jsonRemoved
Workbench UI layoutmotia-workbench.jsonRemoved (see Workbench, Plugins, and Console)
import path from 'node:path'
import { defineConfig, type MotiaPlugin, type MotiaPluginContext, type StreamAuthRequest } from '@motiadev/core'
import bullmqPlugin from '@motiadev/plugin-bullmq/plugin'
import endpointPlugin from '@motiadev/plugin-endpoint/plugin'
import examplePlugin from '@motiadev/plugin-example/plugin'
import logsPlugin from '@motiadev/plugin-logs/plugin'
import observabilityPlugin from '@motiadev/plugin-observability/plugin'
import statesPlugin from '@motiadev/plugin-states/plugin'
import { z } from 'zod'
 
const streamAuthContextSchema = z.object({
  userId: z.string(),
  permissions: z.enum(['nodejs', 'python']).optional(),
})
 
const demoTokens: Record<string, z.infer<typeof streamAuthContextSchema>> = {
  'token-nodejs': { userId: 'anderson', permissions: 'nodejs' },
  'token-python': { userId: 'sergio', permissions: 'python' },
}
 
const extractAuthToken = (request: StreamAuthRequest): string | undefined => {
  const protocol = request.headers['sec-websocket-protocol'] as string | undefined
  if (protocol?.includes('Authorization')) {
    const [, token] = protocol.split(',')
    if (token) return token.trim()
  }
  try {
    const url = new URL(request.url)
    return url.searchParams.get('authToken') ?? undefined
  } catch {
    return undefined
  }
}
 
export default defineConfig({
  plugins: [
    observabilityPlugin,
    statesPlugin,
    endpointPlugin,
    logsPlugin,
    examplePlugin,
    bullmqPlugin,
  ],
  streamAuth: {
    contextSchema: z.toJSONSchema(streamAuthContextSchema),
    authenticate: async (request: StreamAuthRequest) => {
      const token = extractAuthToken(request)
      if (!token) return null
      const tokenData = demoTokens[token]
      if (!tokenData) throw new Error(`Invalid token: ${token}`)
      return tokenData
    },
  },
})

Dev Command

OldNew
motia deviii
motia buildmotia build (unchanged)

Files to Delete

  • motia-workbench.json
  • .motia/ directory — warning: this will delete any local stream and state data persisted by the old engine; back up first if needed

motia.config.ts is not deleted -- it is simplified. Remove the defineConfig wrapper, all plugin imports, and the plugins array. Keep only the authentication hook exports (see the "New" tab above).


Module System and Runtime

The new Motia does not enforce a specific module system or runtime. You are free to use CommonJS, ESM, Node.js, Bun, or any compatible runtime. The framework adapts to your project's setup.

Runtime Support

Motia now has first-class support for Bun in addition to Node.js. You can choose whichever runtime fits your project:

RuntimeDev Command ExampleProduction Example
Node.jsnpx motia devnode dist/index-production.js
Bunbun run dist/index-dev.jsbun run --enable-source-maps dist/index-production.js

Module System

You can use either CommonJS or ESM -- the choice is yours. If you want to adopt ESM (recommended for Bun compatibility and modern tooling), update your project:

package.json (optional)
{
  "type": "module"
}
tsconfig.json (optional)
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "moduleDetection": "force"
  }
}

If you prefer to stay on CommonJS, that works too. Motia does not force a migration.


Steps and Triggers

No more step types

This is the most important conceptual change in new Motia: there are no longer separate "step types". In the old version, you had API steps, Event steps, and Cron steps -- each with its own config type. In the new version, everything is just a Step. What used to determine the "type" of a step is now expressed through its triggers -- an array of trigger definitions that describe how and when the step is activated. A single step can have multiple triggers of different kinds (HTTP, queue, cron, state, stream).

Config Type Changes

OldNew
ApiRouteConfigStepConfig
EventConfigStepConfig
CronConfigStepConfig
type: 'api' | 'event' | 'cron'triggers: [{ type: 'http' | 'queue' | 'cron' | 'state' | 'stream' }]
emits: ['topic']enqueues: ['topic']
subscribes: ['topic']Moved into trigger: { type: 'queue', topic: '...' }

Handler Type Changes

OldNew
Handlers['StepName']Handlers<typeof config>
ctx.emit({ topic, data })ctx.enqueue({ topic, data })

Type Safety

The new version uses as const satisfies StepConfig for full type inference:

export const config: ApiRouteConfig = {
  type: 'api',
  name: 'MyStep',
  // ...
}
export const handler: Handlers['MyStep'] = async (req, ctx) => { /* ... */ }

HTTP Triggers

In the old version these were "API steps" -- a dedicated step type with type: 'api'. In the new version, HTTP is just a trigger type (type: 'http') on a regular step.

import { ApiRouteConfig, Handlers } from 'motia'
import { z } from 'zod'
 
const bodySchema = z.object({ name: z.string(), email: z.string() })
 
export const config: ApiRouteConfig = {
  type: 'api',
  name: 'CreateUser',
  description: 'Create a new user',
  method: 'POST',
  path: '/users',
  bodySchema,
  responseSchema: {
    200: z.object({ id: z.string() }),
    400: z.object({ error: z.string() }),
  },
  emits: ['user-created'],
  flows: ['User Flow'],
  middleware: [coreMiddleware, validateBearerToken],
}
 
export const handler: Handlers['CreateUser'] = async (req, { emit, logger }) => {
  const { name, email } = req.body
  logger.info('Creating user', { name, email })
  await emit({ topic: 'user-created', data: { name, email } })
  return { status: 200, body: { id: 'user-123' } }
}

Key differences:

  1. type: 'api' is now type: 'http' inside a trigger object.
  2. method, path, bodySchema, responseSchema all move inside the trigger.
  3. emits becomes enqueues at the config level.
  4. emit() becomes enqueue() in the handler context.
  5. middleware is removed from step config (see Middleware).
  6. Config type changes from ApiRouteConfig to StepConfig with as const satisfies.

HTTP Helper Shorthand

import { http } from 'motia'
 
export const config = {
  name: 'CreateTodo',
  flows: ['todo-app'],
  triggers: [
    http('POST', '/todo', {
      bodySchema: z.object({ description: z.string() }),
      responseSchema: { 200: todoSchema, 400: z.object({ error: z.string() }) },
    }),
  ],
  enqueues: [],
} as const satisfies StepConfig

Queue Triggers (formerly Event Steps)

The concept of "event steps" that subscribe to topics no longer exists as a step type. Instead, subscribing to a topic is now a queue trigger on a regular step.

import { EventConfig, Handlers } from 'motia'
import { z } from 'zod'
 
export const config: EventConfig = {
  type: 'event',
  name: 'DeployEnvironment',
  description: 'Creates or updates an environment',
  subscribes: ['deploy-environment-v2'],
  emits: ['deploy-version-v2'],
  input: z.object({
    deploymentId: z.string(),
    envVars: z.record(z.string()),
  }),
  flows: ['Deployment'],
}
 
export const handler: Handlers['DeployEnvironment'] = async (data, { logger, emit, streams }) => {
  logger.info('Deploying environment', { deploymentId: data.deploymentId })
  await emit({ topic: 'deploy-version-v2', data: { deploymentId: data.deploymentId } })
}
OldNew
type: 'event'triggers: [{ type: 'queue', topic, input }]
subscribes: ['topic']topic field inside trigger
emits: ['topic']enqueues: ['topic']
input: schemainput: schema inside trigger (or wrap with jsonSchema())
emit({ topic, data })enqueue({ topic, data })

Using jsonSchema() Wrapper

When the input schema needs JSON schema conversion for the engine, use the jsonSchema() wrapper:

import { jsonSchema } from 'motia'
 
triggers: [
  {
    type: 'queue',
    topic: 'notification',
    input: jsonSchema(
      z.object({ email: z.string(), templateId: z.string() })
    ),
  },
]

Cron Triggers

import { CronConfig, Handlers } from 'motia'
 
export const config: CronConfig = {
  type: 'cron',
  name: 'DailyMetricsCollection',
  description: 'Collects metrics daily at midnight',
  cron: '0 5 * * *',
  emits: ['collect-metrics'],
  flows: ['Metrics Collection Flow'],
}
 
export const handler: Handlers['DailyMetricsCollection'] = async ({ logger, emit }) => {
  logger.info('Collecting metrics')
  await emit({ topic: 'collect-metrics', data: { targetDate: new Date().toISOString() } })
}
OldNew
type: 'cron' at config roottriggers: [{ type: 'cron', expression }]
cron: '0 5 * * *' (5-field)expression: '0 0 5 * * * *' (7-field, includes seconds and year)
Handler: async ({ logger, emit })Handler: async (input, { logger, enqueue })
emit()enqueue()

Cron Expression Format

The new engine uses a 7-field cron expression:

┌──────────── second (0-59)
│ ┌────────── minute (0-59)
│ │ ┌──────── hour (0-23)
│ │ │ ┌────── day of month (1-31)
│ │ │ │ ┌──── month (1-12)
│ │ │ │ │ ┌── day of week (0-6, Sun=0)
│ │ │ │ │ │ ┌ year (optional)
│ │ │ │ │ │ │
* * * * * * *
Old (5-field)New (7-field)Meaning
0 5 * * *0 0 5 * * * *Daily at 5:00 AM
0 2 * * *0 0 2 * * * *Daily at 2:00 AM
*/5 * * * *0 */5 * * * * *Every 5 minutes
0 0 * * 00 0 0 * * 0 *Weekly on Sunday at midnight

Streams

Stream definitions remain similar but the access API has changed.

Stream Config

import { StreamConfig } from 'motia'
import { z } from 'zod'
 
export const config: StreamConfig = {
  name: 'deployment',
  baseConfig: { storageType: 'default' },
  schema: z.object({
    id: z.string(),
    status: z.enum(['pending', 'progress', 'completed', 'failed']),
    message: z.string().optional(),
  }),
}

Stream Operations API

OperationOldNew
Getstreams.name.get(id, key)streams.name.get(groupId, id)
Setstreams.name.set(id, key, value)streams.name.set(groupId, id, value)
UpdateN/Astreams.name.update(groupId, id, UpdateOp[])
Deletestreams.name.delete(id, key)streams.name.delete(groupId, id)

The parameter naming changed from (id, key) to (groupId, id) to better reflect the data model: a stream is partitioned by groups, and within each group items are identified by id.

Atomic Updates with UpdateOp

import type { UpdateOp } from 'motia'
 
await streams.deployment.update('merge-groups', traceId, [
  { type: 'increment', path: 'completedSteps', by: 1 },
  { type: 'set', path: 'status', value: 'progress' },
  { type: 'decrement', path: 'retries', by: 1 },
])
TypeFieldsDescription
setpath, valueSet a field to a value (overwrite)
mergepath (optional), valueMerge an object into the existing value (object-only)
incrementpath, byIncrement a numeric field
decrementpath, byDecrement a numeric field
removepathRemove a field entirely

Migration Example

const streamData = await streams.deployment.get(deploymentId, 'data')
streamData.status = 'completed'
streamData.message = 'Done'
await streams.deployment.set(deploymentId, 'data', streamData)

Stream Triggers

Steps can now react to stream changes. The handler receives a StreamWrapperMessage:

type StreamWrapperMessage<TStreamData> = {
  type: 'stream'
  timestamp: number
  streamName: string
  groupId: string
  id?: string
  event: StreamCreate<TStreamData> | StreamUpdate<TStreamData> | StreamDelete<TStreamData> | StreamEvent
}

Where the event field contains one of:

  • { type: 'create', data: TStreamData } -- a new item was created
  • { type: 'update', data: TStreamData } -- an existing item was updated
  • { type: 'delete', data: TStreamData } -- an item was deleted
  • { type: 'event', data: { type: string, data: TEventData } } -- a custom event
triggers: [
  {
    type: 'stream',
    streamName: 'deployment',
    groupId: 'data',
    condition: (input: StreamWrapperMessage) => input.event.type === 'update',
  },
]

State

State provides key-value storage grouped by a namespace. The core get, set, and list operations remain the same. The new version introduces two additions: atomic updates via the update method, and state triggers.

Existing API (unchanged)

await ctx.state.set('orders', orderId, orderData)
const order = await ctx.state.get<Order>('orders', orderId)
const allOrders = await ctx.state.list<Order>('orders')

Atomic Updates with update()

New feature

The update() method eliminates race conditions from manual get-then-set patterns by performing atomic operations.

await ctx.state.update<Order>('orders', orderId, [
  { type: 'increment', path: 'completedSteps', by: 1 },
  { type: 'set', path: 'status', value: 'shipped' },
  { type: 'decrement', path: 'retries', by: 1 },
])

Uses the same UpdateOp interface as streams. See Streams for the full list of operations.

State Triggers

Brand new feature

State triggers enable powerful reactive patterns -- for example, triggering a step when a parallel merge completes, without polling or manual coordination.

import type { StateTriggerInput } from 'motia'
 
export const config = {
  name: 'OnAllStepsComplete',
  triggers: [
    {
      type: 'state',
      condition: (input: StateTriggerInput<MyType>) => {
        return (
          input.group_id === 'tasks' &&
          !!input.new_value &&
          input.new_value.totalSteps === input.new_value.completedSteps
        )
      },
    },
  ],
  flows: ['my-flow'],
} as const satisfies StepConfig

The handler receives the state change event as its first argument, including new_value, old_value, item_id, and group_id.


Middleware

Old Approach

import { ApiMiddleware } from 'motia'
 
export const validateBearerToken: ApiMiddleware = async (req, ctx, next) => {
  const authToken = req.headers['authorization'] as string
  if (!authToken) {
    return { status: 401, body: { error: 'Unauthorized' } }
  }
  req.tokenInfo = decoded
  return next()
}
 
// In step config:
export const config: ApiRouteConfig = {
  type: 'api',
  name: 'GetUser',
  middleware: [coreMiddleware, validateBearerToken],
}

New Approach

The middleware field has been removed from step configs. Authentication is now handled at the engine level:

  1. Stream authentication is configured in motia.config.ts via authenticateStream.
  2. API authentication should be handled within the step handler itself, or via shared utility functions.
  3. Error handling (previously coreMiddleware) should be handled within handlers using try/catch.
Migration strategy
export async function requireAuth(request: ApiRequest<any>): Promise<TokenData> {
  const authToken = request.headers['authorization'] as string
  if (!authToken) {
    throw new HttpError(401, 'Unauthorized')
  }
  const [, token] = authToken.split(' ')
  return jwt.verify(token, env.JWT_SECRET) as TokenData
}
 
export const handler: Handlers<typeof config> = async (request, { logger }) => {
  const tokenData = await requireAuth(request)
  // ... rest of handler
}

New Features

Multi-Trigger Steps

A single step can now respond to multiple trigger types:

export const config = {
  name: 'ProcessOrder',
  flows: ['orders'],
  triggers: [
    { type: 'queue', topic: 'order.created', input: orderSchema },
    { type: 'http', method: 'POST', path: '/orders/manual', bodySchema: orderSchema },
    { type: 'cron', expression: '* * * * *' },
  ],
  enqueues: ['order.processed'],
} as const satisfies StepConfig

The step() Helper

For multi-trigger steps, the step() helper provides ctx.getData() and ctx.match():

import { http, queue, step } from 'motia'
 
export const stepConfig = {
  name: 'ProcessOrder',
  flows: ['orders'],
  triggers: [
    queue('order.created', { input: orderSchema }),
    http('POST', '/orders', { bodySchema: orderSchema }),
  ],
  enqueues: ['notification'],
}
 
export const { config, handler } = step(stepConfig, async (input, ctx) => {
  const data = ctx.getData()
 
  return ctx.match({
    http: async (request) => {
      return { status: 200, body: { success: true } }
    },
    queue: async (queueInput) => {
      ctx.logger.info('Processing from queue', { queueInput })
    },
  })
})

Conditional Triggers

Triggers can include a condition function that determines whether the step should execute:

triggers: [
  {
    type: 'queue',
    topic: 'order.created',
    input: orderSchema,
    condition: (input, ctx) => {
      return input.amount > 1000
    },
  },
  {
    type: 'http',
    method: 'POST',
    path: '/orders/manual',
    bodySchema: orderSchema,
    condition: (input, ctx) => {
      if (ctx.trigger.type !== 'http') return false
      return input.body.user.verified === true
    },
  },
]

Helper Functions

Shorthand helpers for creating triggers:

import { http, queue } from 'motia'
 
triggers: [
  http('POST', '/todo', { bodySchema: schema, responseSchema: { 200: schema } }),
  queue('process-todo', { input: schema }),
]

Migration Checklist

Project Setup

  • Install the iii engine from iii.dev
  • Create config.yaml with module definitions (stream, state, api, queue, cron, exec)
  • Create motia.config.ts for authentication hooks (if needed)
  • Simplify motia.config.ts: remove defineConfig, all plugin imports, and the plugins array; keep only auth hook exports
  • Delete motia-workbench.json
  • Delete .motia/ directory (warning: this will delete any local stream and state data persisted by the old engine; back up first if needed)
  • Update dev script from motia dev to iii
  • Choose your runtime (Node.js or Bun) and module system (CommonJS or ESM)

Steps

  • Replace all ApiRouteConfig / EventConfig / CronConfig imports with StepConfig
  • Convert all step configs to use triggers[] and enqueues[]
  • Add as const satisfies StepConfig to all configs
  • Replace Handlers['StepName'] with Handlers<typeof config>
  • Rename all emit() calls to enqueue()
  • Rename all emits config fields to enqueues
  • Move subscribes into queue triggers
  • Move method, path, bodySchema, responseSchema into HTTP triggers
  • Change type: 'api' to type: 'http' in all triggers
  • Move cron into cron triggers as expression (and convert to 7-field format)
  • Remove type field from config root
  • Remove middleware field from all step configs
  • Replace virtualEmits with virtualEnqueues (format changes from [{ topic, label }] to ['topic'])

Streams

  • Update stream access calls: get(id, key) to get(groupId, id)
  • Update stream access calls: set(id, key, value) to set(groupId, id, value)
  • Replace read-modify-write patterns with update(groupId, id, UpdateOp[]) where possible
  • Add onJoin / onLeave hooks to stream configs if real-time subscription auth is needed

State

  • Adopt state.update() with UpdateOp[] to replace manual get-then-set patterns
  • Consider using state triggers for reactive workflows

Middleware

  • Extract authentication logic into shared utility functions
  • Extract error handling logic into handler-level try/catch or wrapper functions
  • Remove all middleware imports and references from step configs

Cron Expressions

  • Convert all 5-field cron expressions to 7-field format (prepend seconds, append year)
  • Rename cron field to expression inside trigger objects

Python (if applicable)

  • Install motia as a standalone Python package (npm/Node.js no longer required)
  • Add a separate ExecModule entry in config.yaml for the Python runtime
  • Refer to the dedicated Python migration guide for step-level changes

Workbench and Plugins

  • Delete motia-workbench.json
  • Remove any .ui.step.ts or noop step files used exclusively for workbench rendering
  • Remove any workbench plugin code (React/JSX components for workbench panels)
  • Familiarize with the iii Console as the replacement for the Workbench

Python Runtime

Major change for Python developers

In the old Motia, Python steps were managed by the Node.js runtime. Python developers previously needed Node.js and npm installed. This is no longer the case. Python is now a fully independent runtime.

In the new Motia, runtimes are fully independent. There is a dedicated Motia Python SDK (motia-py) that runs as its own standalone process, communicating directly with the iii engine. Python developers no longer need Node.js, npm, or any JavaScript tooling whatsoever.

AspectOldNew
Python executionSpawned as child process by Node runtimeIndependent process managed by iii engine
Node.js required for Python?YesNo
SDKSingle motia npm package handled bothSeparate motia-py (Python) and motia (Node) packages
ConfigurationShared with Node stepsOwn config.yaml ExecModule entry

For Mixed Projects (Node + Python)

Configure separate ExecModule entries in config.yaml:

modules:
  - class: modules::shell::ExecModule
    config:
      watch:
        - steps/**/*.ts
      exec:
        - npx motia dev
 
  - class: modules::shell::ExecModule
    config:
      watch:
        - steps/**/*.py
      exec:
        - uv run motia dev --dir steps

A dedicated migration guide for Python projects and steps will be provided in a separate document. This guide focuses on the Node.js/TypeScript migration path.


Workbench, Plugins, and Console

Workbench Replaced by iii Console

The Motia Workbench (the local visual flow editor, configured via motia-workbench.json) has been replaced by the iii Console. The console provides a richer experience for visualizing and managing your flows, traces, and infrastructure.

Refer to the iii quickstart documentation for iii Console installation instructions.

Workbench Plugins Sunset

Workbench plugins (custom UI panels and extensions rendered inside the Workbench) have been sunset and are no longer supported. If your project relied on workbench plugins, you will need to find alternative approaches for any custom UI functionality they provided.

  • Delete any .ui.step.ts or noop step files that were used exclusively for workbench rendering.
  • Remove any React/JSX workbench plugin code that is no longer needed.

OpenAPI Generation

OpenAPI spec generation from HTTP step schemas is planned but not yet available in the new Motia version. This section will be updated once the feature is released.