---
title: Migrating from Inngest
description: Move an Inngest TypeScript app to the Workflow SDK by replacing createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, Hooks, and start()/getRun().
type: guide
summary: Translate an Inngest app into the Workflow SDK with side-by-side code examples.
prerequisites:
  - /docs/getting-started/next
  - /docs/foundations/workflows-and-steps
related:
  - /docs/foundations/starting-workflows
  - /docs/foundations/errors-and-retries
  - /docs/foundations/hooks
  - /docs/foundations/streaming
  - /docs/deploying/world/vercel-world
---

# Migrating from Inngest



<Callout type="info">
  Install the Workflow SDK migration skill:

  ```bash
  npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk
  ```
</Callout>

## Why migrate to the 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 workflow` CLI, `@workflow/ai` integration for durable AI agents, and a Claude skill for generating workflows.

## What changes when you leave Inngest

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.

## Concept mapping

| 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()`                                           | Import from `workflow`.                                                                                              |
| `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. |

## Translate your first workflow

Start with the shell of a function. Inngest wraps it in `createFunction`; Workflow SDK marks it with a directive.

{/* @skip-typecheck: Inngest SDK types not available */}

```typescript title="inngest/functions/order.ts"
export const processOrder = inngest.createFunction(
  {
    id: 'process-order',
    triggers: [{ event: 'order/created' }],
  },
  async ({ event, step }) => {
    return { orderId: event.data.orderId, status: 'completed' };
  }
);
```

```typescript title="workflow/workflows/order.ts"
export async function processOrder(orderId: string) {
  'use workflow'; // [!code highlight]
  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.

```typescript title="workflow/workflows/order.ts"
async function loadOrder(orderId: string) {
  'use step'; // [!code highlight]
  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:

```typescript title="app/api/orders/route.ts"
import { start } from 'workflow/api';
import { processOrder } from '@/workflows/order';

const run = await start(processOrder, [orderId]); // [!code highlight]
```

No event bus, no registry. `start()` returns a handle immediately.

## Wait for an external signal

`step.waitForEvent()` becomes `createHook()` plus `await`.

```typescript title="workflow/workflows/refund.ts (Inngest)"
const approval = await step.waitForEvent('wait-for-approval', {
  event: 'refund/approved',
  match: 'data.refundId',
  timeout: '7d',
});
```

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/refund.ts (Workflow SDK)"
using approval = createHook<{ approved: boolean }>({ // [!code highlight]
  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`:

```typescript title="app/api/refunds/[refundId]/approve/route.ts"
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 }); // [!code highlight]
  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()`:

{/* @skip-typecheck: continuation snippet */}

```typescript title="workflow/workflows/refund.ts"
const result = await Promise.race([
  approval.then((p) => ({ type: 'decision' as const, approved: p.approved })),
  sleep('7d').then(() => ({ type: 'timeout' as const })), // [!code highlight]
]);

if (result.type === 'timeout') return { refundId, status: 'timed-out' };
if (!result.approved) return { refundId, status: 'rejected' };
return { refundId, status: 'approved' };
```

<Callout type="info">
  Event matching disappears. A hook's token encodes the routing (for example, `refund:${refundId}:approval`), and the caller supplies that token to `resumeHook()`.
</Callout>

## Spawn a child workflow

`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.

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/parent.ts"
async function spawnChild(item: string) {
  'use step';
  return start(childWorkflow, [item]); // [!code highlight]
}
```

Await the result in a second step, then orchestrate both from the parent:

{/* @skip-typecheck: snippet without imports */}

```typescript title="workflow/workflows/parent.ts"
async function collectResult(runId: string) {
  'use step';
  const run = getRun(runId);
  return await run.returnValue; // [!code highlight]
}

export async function parentWorkflow(item: string) {
  'use workflow';
  const child = await spawnChild(item);
  return await collectResult(child.runId);
}
```

## What you stop operating

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.

## Step-by-step first migration

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

Add the runtime and the framework integration that matches the app.

```bash
pnpm add workflow
```

### 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.

```ts title="workflows/order.ts"
// 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"; // [!code highlight]
  // ...
}
```

### 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`.

```ts
async function loadOrder(id: string) {
  "use step"; // [!code highlight]
  return fetch(`/api/orders/${id}`).then((r) => r.json());
}
```

### Step 4: Replace `waitForEvent`, `sleep`, and `invoke`

* `step.waitForEvent(...)` → `createHook({ token })` + `await hook`. Resume it from an API route with `resumeHook(token, payload)`.
* `step.sleep(...)` → `sleep("5m")` from `workflow`.
* `step.invoke(child, { data })` → wrap `start(child, [data])` in a `"use step"` function that returns the `Run`, and optionally read its return value with `getRun(run.runId).returnValue`.

### Step 5: Start runs from the app

Delete the `serve()` handler and event dispatch. Launch runs directly from an API route:

```ts title="app/api/orders/route.ts"
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.

## Quick-start checklist

* Replace `inngest.createFunction()` with a `"use workflow"` function; launch it with `start()`.
* Convert each `step.run()` callback into a named `"use step"` function.
* Swap `step.sleep()` / `step.sleepUntil()` for `sleep()` from `workflow`.
* Swap `step.waitForEvent()` for `createHook()` (internal) or `createWebhook()` (HTTP).
* Model `waitForEvent` timeouts as `Promise.race()` between the hook and `sleep()`.
* Replace `step.invoke()` with `"use step"` wrappers around `start()` and `getRun()`.
* Replace `step.sendEvent()` fan-out with `start()` 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`, and `FatalError`.
* Use `getStepMetadata().stepId` as the idempotency key for external side effects.
* Replace `step.realtime.publish()` with `getWritable()`.
* Deploy and verify end-to-end with the built-in observability UI.


## Sitemap
[Overview of all docs pages](/sitemap.md)
