Skip to main content
Motia was a higher-level framework built on top of iii-sdk. It handled file scanning, middleware wiring, trigger registration, and production bundling automatically. These conveniences came at a cost: they hid iii’s three core primitives — Workers, Functions, and Triggers — behind opaque abstractions that limited what you could build. By moving to iii-sdk directly, you unlock the full power of the engine:
  • Add new workers that register their own functions and triggers, enabling multi-worker orchestration across services, languages, and runtimes.
  • Connect from the browser using iii-browser-sdk with Worker RBAC for secure, real-time frontends — no REST layer needed. See Use iii in the browser.
  • Treat Motia as one worker among many instead of a standalone monolith. Your existing Motia code becomes just another worker in a larger iii deployment.
  • Understand the primitives directly. Working with registerFunction, registerTrigger, and registerWorker builds a mental model that transfers across all iii SDKs and documentation.
Before diving into this migration, we recommend reading the iii documentation to understand Workers, Functions, and Triggers. The quickstart and Everything is a Worker pages are good starting points.

Step 1 — Update dependencies

Remove motia and add iii-sdk. If you need a production bundler, add esbuild as a dev dependency.
pnpm remove motia
pnpm add iii-sdk@0.11.0
pnpm add -D esbuild
Before (Motia)After (iii-sdk)
motiaiii-sdk@0.11.0
motia buildesbuild via bun run esbuild.config.ts
motia dev && bun run dist/index.jsbun run --watch src/main.ts

Step 2 — Initialize the SDK

Create src/lib/iii.ts. This replaces the implicit connection that Motia managed for you.
@/lib/iii
import { Logger, registerWorker } from 'iii-sdk'

export const iii = registerWorker(process.env.III_URL ?? 'ws://localhost:49134', {
  workerName: 'api-worker',
})

export const logger = new Logger()
Every import { logger } from 'motia' in your codebase changes to import { logger } from '@/lib/iii'.

Step 3 — Migrate handlers

Motia auto-registered functions and triggers from exported config objects. With iii-sdk you call registerFunction and registerTrigger directly.

HTTP

import { type Handlers, http, type StepConfig } from 'motia'
import { z } from 'zod'

export const config = {
  name: 'Health',
  triggers: [http('GET', '/health', { responseSchema: { 200: z.object({ ok: z.boolean() }) } })],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_input, _ctx) => {
  return { status: 200, body: { ok: true } }
}

HTTP with middleware

import { type Handlers, http, type StepConfig } from 'motia'

export const config = {
  name: 'List Tags',
  triggers: [http('GET', '/tag', { middleware: [requireAuth] })],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (input, ctx) => {
  const { sub: userId, orgId } = input.tokenInfo
  return { status: 200, body: { tags: allTags } }
}

Cron

import { cron, type Handlers, type StepConfig } from 'motia'

export const config = {
  name: 'Daily Report',
  triggers: [cron('0 0 9 * * * *')],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (_input, _ctx) => {
  await generateReport()
}

Queue (durable subscriber)

import { type Handlers, queue, type StepConfig } from 'motia'

export const config = {
  name: 'Process Order',
  triggers: [queue('order.created')],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (input, _ctx) => {
  await processOrder(input)
}
To publish to a topic, use iii.trigger instead of Motia’s enqueue:
import { iii } from '@/lib/iii'

iii.trigger({
  function_id: 'iii::durable::publish',
  payload: { topic: 'order.created', data: orderPayload },
})

State

import { type Handlers, state, type StepConfig } from 'motia'

export const config = {
  name: 'On State Change',
  triggers: [state()],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (input, _ctx) => {
  await handleStateChange(input)
}

Stream

import { type Handlers, stream, type StepConfig } from 'motia'

export const config = {
  name: 'Chat Message',
  triggers: [stream('chat', { groupId: 'room-1' })],
  enqueues: [],
} as const satisfies StepConfig

export const handler: Handlers<typeof config> = async (input, _ctx) => {
  await handleChatMessage(input)
}

Multiple triggers on one function

A function can have multiple triggers. This was possible in Motia via the triggers array, and maps directly to multiple registerTrigger calls sharing the same function_id:
const ref = iii.registerFunction('order-handler', async (input) => {
  await handleOrder(input)
})

iii.registerTrigger({
  type: 'http',
  function_id: ref.id,
  config: { api_path: '/orders', http_method: 'POST' },
})

iii.registerTrigger({
  type: 'durable:subscriber',
  function_id: ref.id,
  config: { topic: 'order.retry' },
})

Stream and State operations

In Motia, you used the Stream class and stateManager to read and write data. Under the hood, these called iii.trigger() with built-in function IDs (stream::get, state::set, etc.). With iii-sdk, you call iii.trigger() directly. See the Stream and State worker docs for the full list of operations.
import { iii } from '@/lib/iii'

const todo = await iii.trigger({
  function_id: 'stream::get',
  payload: { stream_name: 'todos', group_id: 'user-1', item_id: 'todo-1' },
})

await iii.trigger({
  function_id: 'stream::set',
  payload: { stream_name: 'todos', group_id: 'user-1', item_id: 'todo-1', data: { title: 'Buy milk' } },
})

await iii.trigger({
  function_id: 'stream::delete',
  payload: { stream_name: 'todos', group_id: 'user-1', item_id: 'todo-1' },
})

const items = await iii.trigger({
  function_id: 'stream::list',
  payload: { stream_name: 'todos', group_id: 'user-1' },
})
Additional function IDs: stream::update, stream::list_groups, stream::send for streams; state::update, state::list_groups for state.

Stream lifecycle hooks (onJoin / onLeave)

In Motia, *.stream.ts files could define onJoin and onLeave callbacks inside the StreamConfig. The framework registered a single shared function for each hook and dispatched by stream name internally. With iii-sdk, you register these as regular functions with stream:join and stream:leave triggers. The handler receives a StreamJoinLeaveEvent with { stream_name, group_id, id, context }.
import { type StreamConfig } from 'motia'

export const config: StreamConfig = {
  name: 'todo',
  schema: todoSchema,
  baseConfig: { storageType: 'default' },

  onJoin: async (subscription, _context, authContext) => {
    return { unauthorized: false }
  },

  onLeave: async (subscription, _context, authContext) => {
    // cleanup logic
  },
}
If you had multiple streams with different onJoin / onLeave logic, dispatch by event.stream_name inside the handler or register separate function IDs per stream.

Stream authentication (authenticateStream)

In Motia, motia.config.ts exported an authenticateStream function that ran during WebSocket upgrade. With iii-sdk, you register a function directly and reference its ID in the engine’s stream module config.
// motia.config.ts
import type { AuthenticateStream } from 'motia'

export const authenticateStream: AuthenticateStream = async (req, context) => {
  return { context: { userId: 'sergio' } }
}
The engine calls this function during WebSocket upgrade. Reference the function ID in your config.yaml:
workers:
  - name: iii-stream
    config:
      auth_function: stream::authenticate
Key differences:
  • Motia used queryParams (camelCase); iii-sdk uses query_params (snake_case).
  • No trigger registration is needed for auth — it is config-driven via auth_function.
  • The motia.config.ts file is no longer needed; delete it.

Step 4 — Update request and response shapes

Request properties

Motia wrapped iii-sdk’s HTTP request into friendlier property names. With iii-sdk you receive the raw shape.
PropertyMotia (input.*)iii-sdk (input.*)
Route paramsparamspath_params
Query stringqueryquery_params
JSON bodybodybody
Headersheadersheaders
HTTP methodmethodmethod
Request streamrequestBodyrequest_body
Response streamresponseresponse
// Before (Motia)
const { folderId } = input.params || {}
const parentFolderId = input.query?.parentFolderId

// After (iii-sdk)
const { folderId } = input.path_params || {}
const parentFolderId = input.query_params?.parentFolderId

query_params value types

In iii-sdk, query_params values are string | string[]. Use a helper to safely extract the first value:
function firstQueryParam(
  queryParams: Record<string, string | string[]> | undefined,
  name: string,
): string | undefined {
  const v = queryParams?.[name]
  if (v === undefined) return undefined
  return Array.isArray(v) ? v[0] : v
}

Response shape

iii-sdk uses status_code instead of status. This applies to every handler return and middleware response.
// Before (Motia)
return { status: 200, body: { tags: allTags } }
return { status: 404, body: { error: 'Not found' } }

// After (iii-sdk)
return { status_code: 200, body: { tags: allTags } }
return { status_code: 404, body: { error: 'Not found' } }

Step 5 — Set up the entry point

Motia discovered .step.ts files automatically. With iii-sdk, you create a single entry point that imports all handler files as side effects. Create src/main.ts:
import './lib/iii'
import './handlers/health'
import './handlers/auth'
import './handlers/orders/create-order'
import './handlers/orders/list-orders'
import './handlers/reports/daily-report'
// ... import every handler file
Each import executes the registerFunction and registerTrigger calls at the module level, registering the function and its triggers with the engine.

File renaming

Rename all *.step.ts files to *.ts:
src/steps/health.step.ts  →  src/handlers/health.ts
src/steps/auth.step.ts    →  src/handlers/auth.ts
The directory structure is yours to decide — handlers/, routes/, rest/, or any layout that fits your project.

Step 6 — Development workflow

Replace Motia’s dev command with a direct bun watcher. In your config.yaml, configure the worker under iii-exec:
workers:
  - name: iii-exec
    config:
      exec:
        - bun run --watch src/main.ts
Run the engine, and it will start your worker process with file watching built in.

Step 7 — Production build

Motia had motia build. Replace it with a plain esbuild config. Add to package.json:
{
  "scripts": {
    "build": "bun run esbuild.config.ts"
  }
}
Create esbuild.config.ts:
import * as esbuild from 'esbuild'

esbuild.build({
  entryPoints: ['src/main.ts'],
  outfile: 'dist/index-production.js',
  bundle: true,
  platform: 'node',
  target: ['node22'],
  format: 'esm',
  minify: true,
  sourcemap: true,
  external: ['ws'],
}).catch((err) => {
  console.error(err)
  process.exit(1)
})
Key decisions:
  • bundle: true — all application code and npm dependencies are bundled into a single file.
  • external: ['ws'] — the ws package has native addons and must remain in node_modules at runtime.
  • sourcemap: true — run production with bun run --enable-source-maps dist/index-production.js so stack traces map back to TypeScript source.
  • platform: 'node' — Node.js built-ins (fs, path, crypto, etc.) are treated as external.
  • format: 'esm' — matches "type": "module" in your package.json.
Update your production config to run the bundled output:
workers:
  - name: iii-exec
    config:
      exec: 
        - bun run --enable-source-maps dist/index-production.js

Migration checklist

  • Remove motia from dependencies, add iii-sdk@0.11.0
  • Add esbuild as a dev dependency
  • Create src/lib/iii.ts with registerWorker and Logger
  • Rename all *.step.ts files to *.ts
  • Replace all import { ... } from 'motia' with iii-sdk imports
  • Convert every config + handler export to registerFunction + registerTrigger calls
  • Replace status with status_code in every handler return
  • Replace params with path_params and query with query_params
  • Replace requestBody with request_body in streaming handlers
  • Replace Stream and stateManager usage with iii.trigger() calls or custom wrappers
  • Migrate stream onJoin / onLeave hooks to registerFunction + registerTrigger (stream:join / stream:leave)
  • Migrate authenticateStream from motia.config.ts to a registered function and set auth_function in config.yaml
  • Delete motia.config.ts
  • Create src/main.ts with side-effect imports for every handler
  • Replace motia dev with bun run --watch src/main.ts in your dev config
  • Replace motia build with an esbuild config
  • Test all endpoints, cron jobs, queue subscribers, and stream handlers