Move an Inngest TypeScript SDK v3 or v4 app to the Workflow SDK by replacing createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, Hooks, and start()/getRun().
Install the Workflow SDK migration skill:
npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk- Streaming is built in. Durable progress writes go to named streams via
getWritable(). There is no separate realtime publish channel or WebSocket layer to operate. - Infrastructure and orchestration live in a single deployment. Workflows run where the app runs. There is no separate Inngest Dev Server or event bus to operate.
- TypeScript-first DX. Steps are named async functions marked with
"use step". No inline closures tied to a framework-specific lifecycle. - Agent-first tooling: the
npx workflowCLI,@workflow/aiintegration for durable AI agents, and a Claude skill for generating workflows.
Inngest code samples in this guide use the v4 two-argument createFunction({ id, triggers }, handler) shape. If the app is still on Inngest v3, treat the three-argument form createFunction({ id }, { event: '...' }, handler) as equivalent — the triggers option in v4 replaces the second argument in v3.
Inngest defines functions with inngest.createFunction(), registers them through a serve() handler, and breaks work into steps with step.run(), step.sleep(), and step.waitForEvent(). The platform routes events, schedules steps, and applies retries.
The Workflow SDK replaces that with "use workflow" functions that orchestrate "use step" functions in plain TypeScript. There is no function registry, event dispatch layer, or SDK client. Durable replay, automatic retries, and step-level persistence are built into the runtime.
Migration collapses the SDK abstraction into plain async functions. Business logic stays the same.
Inngest's event-bus model is loosely coupled — publishers don't know consumers. start() requires the caller to import the workflow function directly, giving stronger type safety but tighter coupling. For event-bus-like fan-out, wrap start() in a shared publisher module.
| Inngest | Workflow SDK | Migration note |
|---|---|---|
inngest.createFunction() | "use workflow" function started with start() | No wrapper needed. |
step.run() | "use step" function | Standalone async function with Node.js access. |
step.sleep() / step.sleepUntil() | sleep() | sleep('5m') for a duration; sleep(date) for sleep-until. |
step.waitForEvent() | createHook() or createWebhook() | Hooks for typed signals, webhooks for HTTP. |
step.invoke() | "use step" wrappers around start() / getRun() | Spawn a child run, pass runId forward. |
inngest.send() / event triggers | start() from your app boundary | Start workflows directly. |
Retry configuration (retries) | RetryableError, FatalError, maxRetries | Retry logic lives at the step level. |
step.sendEvent() | "use step" wrapper around start() | Fan out via start(), not an event bus. |
Realtime / step.realtime.publish() | getWritable() / getWritable({ namespace }) | Named streams are the canonical way for clients to read workflow status. No database or getRun() polling required. |
Start with the shell of a function. Inngest wraps it in createFunction; Workflow SDK marks it with a directive.
export const processOrder = inngest.createFunction(
{
id: 'process-order',
triggers: { event: 'order/created' },
},
async ({ event, step }) => {
return { orderId: event.data.orderId, status: 'completed' };
}
);export async function processOrder(orderId: string) {
'use workflow';
return { orderId, status: 'completed' };
}What changed: the factory + event binding collapses into a plain exported function with a "use workflow" directive.
Add a step
step.run() closures become named "use step" functions.
async function loadOrder(orderId: string) {
'use step';
const res = await fetch(`https://example.com/api/orders/${orderId}`);
return res.json() as Promise<{ id: string }>;
}Call it from the workflow like any async function: const order = await loadOrder(orderId). Additional side effects (reserveInventory, chargePayment) follow the same shape.
Start the run
Inngest dispatches via inngest.send({ name: 'order/created', data: { orderId } }). Workflow SDK launches directly:
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';
const run = await start(processOrder, [orderId]); No event bus, no registry. start() returns a handle immediately.
step.waitForEvent() becomes createHook() plus await.
const approval = await step.waitForEvent('wait-for-approval', {
event: 'refund/approved',
match: 'data.refundId',
timeout: '7d',
});using approval = createHook<{ approved: boolean }>({
token: `refund:${refundId}:approval`,
});
const payload = await approval;What changed: event name + match expression collapse into a single token string. The caller supplies that token directly, with no event schema.
Resume from an API route
Inngest resumes with inngest.send({ name: 'refund/approved', data: { refundId, approved } }). The SDK equivalent is resumeHook:
import { resumeHook } from 'workflow/api';
export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) {
const { refundId } = await params;
const { approved } = (await request.json()) as { approved: boolean };
await resumeHook(`refund:${refundId}:approval`, { approved });
return Response.json({ ok: true });
}Add a timeout and branch on the payload
Inngest's timeout: '7d' option maps to a Promise.race() with sleep():
const result = await Promise.race([
approval.then((p) => ({ type: 'decision' as const, approved: p.approved })),
sleep('7d').then(() => ({ type: 'timeout' as const })),
]);
if (result.type === 'timeout') return { refundId, status: 'timed-out' };
if (!result.approved) return { refundId, status: 'rejected' };
return { refundId, status: 'approved' };Event matching disappears. A hook's token encodes the routing (for example, refund:${refundId}:approval), and the caller supplies that token to resumeHook().
step.invoke() splits into two steps: spawn and collect. start() and getRun() are runtime APIs, so wrap them in "use step" functions. Return the Run object from the spawn step so observability can deep-link into the child run.
You can return either the full Run object (enables deep-linking) or just run.runId (simpler).
async function spawnChild(item: string) {
'use step';
return start(childWorkflow, [item]);
}Await the result in a second step, then orchestrate both from the parent:
async function collectResult(runId: string) {
'use step';
const run = getRun(runId);
return await run.returnValue;
}
export async function parentWorkflow(item: string) {
'use workflow';
const child = await spawnChild(item);
return await collectResult(child.runId);
}Dropping the Inngest SDK removes several moving parts:
- No SDK client or serve handler. Workflow files carry directive annotations. No registry, no serve endpoint.
- No event bus.
start()launches workflows directly from API routes, server actions, or other entry points. No event schemas or dispatch layer. - No inline step closures. Steps are named async functions. They type-check and test like any other TypeScript function.
- No separate streaming transport.
getWritable()delivers progress to clients without WebSockets or SSE glue. - No idle workers. Workflows suspended on
sleep()or a hook consume no compute until resumed.
Pick one Inngest function and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.
Step 1: Install the Workflow SDK
Install the SDK. Framework integrations (workflow/next, workflow/nitro, workflow/nuxt, etc.) are subpath exports of the same workflow package — no additional install needed.
Step 2: Convert createFunction to a "use workflow" export
Replace the factory call with a plain async export. Move the handler body up. The event-binding argument goes away.
// Before (Inngest)
// export const processOrder = inngest.createFunction(
// { id: "process-order", triggers: { event: "order/created" } },
// async ({ event, step }) => { ... }
// );
// After (Workflow SDK)
export async function processOrder(orderId: string) {
"use workflow";
// ...
}Step 3: Convert step.run callbacks into named step functions
Each inline callback becomes a named function with "use step" on the first line. The workflow calls them with a plain await.
async function loadOrder(id: string) {
"use step";
return fetch(`https://example.com/api/orders/${id}`).then((r) => r.json());
}Configure per-step retry counts by assigning maxRetries as a function property:
async function callApi(endpoint: string) {
"use step";
const response = await fetch(endpoint);
return response.json();
}
callApi.maxRetries = 5;See Errors and retries for full retry docs.
Step 4: Replace waitForEvent, sleep, and invoke
step.waitForEvent(...)→createHook({ token })+await hook. Resume it from an API route withresumeHook(token, payload).step.sleep(...)→sleep("5m")fromworkflow.step.invoke(child, { data })→ wrapstart(child, [data])in a"use step"function that returns theRun, and optionally read its return value withgetRun(run.runId).returnValue.
Step 5: Start runs from the app
Delete the serve() handler and event dispatch. Launch runs directly from an API route:
import { start } from "workflow/api";
import { processOrder } from "@/workflows/order";
export async function POST(req: Request) {
const { orderId } = await req.json();
const run = await start(processOrder, [orderId]);
return Response.json({ runId: run.runId });
}Step 6: Retire the Inngest infrastructure
Remove the inngest client, the serve() route, event schemas, and the Inngest Dev Server from the app. Verify the run in npx workflow web before shipping.
- Cron / scheduled functions (
triggers: { cron: '...' }). The SDK has no built-in scheduler. Trigger runs from Vercel Cron or a system cron callingstart()from an API route. - Concurrency, throttling, rate limiting, debounce, singleton, priority, and
batchEvents. These function-level settings have no direct analog. Enforce limits inside steps (semaphores, external rate-limiter service) or debounce at the publisher before callingstart(). EventSchemas/ typed events. The event-bus indirection goes away; publishers import the workflow function directly, giving the same type safety through a different mechanism.- Event-bus fan-out by name match.
inngest.send()that triggered multiple functions by event name must be replaced by explicitstart()calls for each target workflow.
- Replace
inngest.createFunction()with a"use workflow"function; launch it withstart(). - Convert each
step.run()callback into a named"use step"function. - Swap
step.sleep()/step.sleepUntil()forsleep()fromworkflow. - Swap
step.waitForEvent()forcreateHook()(internal) orcreateWebhook()(HTTP). - Model
waitForEventtimeouts asPromise.race()between the hook andsleep(). - Replace
step.invoke()with"use step"wrappers aroundstart()andgetRun(). - Replace
step.sendEvent()fan-out withstart()called from a"use step"function. - Remove the Inngest client,
serve()handler, and event definitions. - Push retry configuration down to step boundaries via
maxRetries,RetryableError, andFatalError. - Use
getStepMetadata().stepIdas the idempotency key for external side effects. - Replace
step.realtime.publish()withgetWritable(). - Deploy and verify end-to-end with the built-in observability UI.
Verified against workflow@5.0.0-beta.1 and Inngest TypeScript SDK v4 on 2026-04-16.























