diff --git a/docs/content/docs/migration-guides/index.mdx b/docs/content/docs/migration-guides/index.mdx index bc28b14d36..7f1cd91836 100644 --- a/docs/content/docs/migration-guides/index.mdx +++ b/docs/content/docs/migration-guides/index.mdx @@ -1,23 +1,34 @@ --- title: Migration Guides -description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and realistic migration examples. +description: Move your existing durable workflow system to the Workflow SDK with side-by-side code comparisons and a realistic migration example. type: overview -summary: Migrate from Temporal, Inngest, or AWS Step Functions to the Workflow SDK. +summary: Migrate from Temporal, Inngest, AWS Step Functions, or trigger.dev to the Workflow SDK. related: - /docs/foundations/workflows-and-steps - /docs/getting-started --- -Migrate your existing orchestration system to the Workflow SDK. Each guide includes concept mappings, side-by-side code comparisons, and a full end-to-end migration example. + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + +Move an existing orchestration system to the Workflow SDK. Each guide pairs a concept-mapping table with side-by-side code, so you can translate one piece of your codebase at a time. - Replace Activities, Workers, Signals, and Child Workflows with Workflows, Steps, Hooks, and start()/getRun(). + Map Activities, Workers, Signals, and Child Workflows onto workflows, steps, hooks, and `start()` / `getRun()`. - Replace createFunction, step.run(), step.sleep(), step.waitForEvent(), and step.invoke() with Workflows, Steps, and Hooks. + Map `createFunction`, `step.run`, `step.sleep`, `step.waitForEvent`, and `step.invoke` onto workflows, steps, and hooks. - Replace JSON state definitions, Task/Choice/Wait/Parallel states, and .waitForTaskToken callbacks with idiomatic TypeScript. + Replace ASL JSON states, Task / Choice / Wait / Parallel states, and `.waitForTaskToken` callbacks with TypeScript. + + + Map `task()`, `schemaTask()`, `wait.for` / `wait.forToken`, `triggerAndWait`, and `metadata.stream` onto workflows, steps, hooks, and `start()` / `getRun()`. diff --git a/docs/content/docs/migration-guides/meta.json b/docs/content/docs/migration-guides/meta.json index 5682025c55..546403fbd0 100644 --- a/docs/content/docs/migration-guides/meta.json +++ b/docs/content/docs/migration-guides/meta.json @@ -3,6 +3,7 @@ "pages": [ "migrating-from-temporal", "migrating-from-inngest", - "migrating-from-aws-step-functions" + "migrating-from-aws-step-functions", + "migrating-from-trigger-dev" ] } diff --git a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx index 65420dec15..2470a60927 100644 --- a/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx +++ b/docs/content/docs/migration-guides/migrating-from-aws-step-functions.mdx @@ -2,7 +2,7 @@ title: Migrating from AWS Step Functions description: Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions, Task states, Choice/Wait/Parallel states, Retry/Catch blocks, and .waitForTaskToken callbacks with Workflows, Steps, Hooks, and idiomatic TypeScript control flow. type: guide -summary: Translate an AWS Step Functions state machine into the Workflow SDK with side-by-side code and a realistic order-processing saga. +summary: Translate an AWS Step Functions state machine into the Workflow SDK with side-by-side code examples. prerequisites: - /docs/getting-started/next - /docs/foundations/workflows-and-steps @@ -14,503 +14,297 @@ related: - /docs/deploying/world/vercel-world --- +Move an AWS Step Functions state machine to the Workflow SDK by replacing JSON state definitions with TypeScript functions. This guide shows the direct mapping between ASL states and Workflow SDK primitives. + + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + +## Why migrate to the Workflow SDK + +- Orchestration code is TypeScript, not JSON ASL. Transitions are `await`, branches are `if`/`switch`, and parallelism is `Promise.all`. +- Streaming is built in. Write durable progress from steps with `getWritable()` and named streams. No DynamoDB or SNS glue to surface status to clients. +- Infrastructure lives in one deployment. No separate state machine, per-task Lambda, IAM role wiring, or callback SQS queues. +- Error handling is TypeScript-native: step-level retries, `RetryableError`, and `FatalError` replace per-state Retry/Catch blocks. +- Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` integration, and the Claude skill are available out of the box. + ## What changes when you leave Step Functions? -With AWS Step Functions, you define workflows as JSON state machines using Amazon States Language (ASL). Each state — Task, Choice, Wait, Parallel, Map — is a node in a declarative graph. You wire Lambda functions as task handlers, configure Retry/Catch blocks per state, and manage callback patterns through `.waitForTaskToken`. The execution engine is powerful, but the authoring experience is configuration-heavy and detached from your application code. +AWS Step Functions defines workflows as JSON state machines using Amazon States Language (ASL). Each state (Task, Choice, Wait, Parallel, Map) is a node in a declarative graph. Lambda functions handle tasks, Retry/Catch blocks configure per-state error handling, and `.waitForTaskToken` manages callbacks. -With the Workflow SDK, you write `"use workflow"` functions that orchestrate `"use step"` functions — all in the same file, all plain TypeScript. Branching is `if`/`else`, waiting is `sleep()`, parallelism is `Promise.all()`, and retries are step-level configuration. There is no state-machine JSON to maintain, no Lambda function wiring, and no IAM roles to configure between orchestrator and compute. +The Workflow SDK replaces that JSON DSL with TypeScript. `"use workflow"` functions orchestrate `"use step"` functions in the same file. Branching is `if`/`else`. Waiting is `sleep()`. Parallelism is `Promise.all()`. Retries move down to the step level. -The migration path is mostly about **replacing declarative configuration with idiomatic TypeScript** and **collapsing the orchestrator/compute split**, not rewriting business logic. +The migration replaces declarative configuration with idiomatic TypeScript and collapses the orchestrator and compute split. Business logic stays the same. ## Concept mapping | AWS Step Functions | Workflow SDK | Migration note | | --- | --- | --- | -| State machine (ASL JSON) | `"use workflow"` function | The workflow function _is_ the state machine — expressed as async TypeScript. | -| Task state / Lambda | `"use step"` function | Put side effects and Node.js access in steps. No separate Lambda deployment. | -| Choice state | `if` / `else` / `switch` | Use native TypeScript control flow instead of JSON condition rules. | -| Wait state | `sleep()` | Import `sleep` from `workflow` and call it in your workflow function. | -| Parallel state | `Promise.all()` | Run steps concurrently with standard JavaScript concurrency primitives. | -| Map state | Loop + `Promise.all()` or batched child workflows | Iterate over items with `for`/`map` and parallelize as needed. | -| Retry / Catch | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retry logic moves down to the step level with `try`/`catch` for error handling. | -| `.waitForTaskToken` | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| Child state machine (`StartExecution`) | `"use step"` wrappers around `start()` / `getRun()` | Start the child from a step, return its `runId`, then poll or await the child result from another step. | -| Execution event history | Workflow event log / run timeline | Same durable replay idea, fewer surfaces to manage directly. | - -## Side-by-side: hello workflow - -### AWS Step Functions - -State machine definition (ASL): - -```json -{ - "StartAt": "LoadOrder", - "States": { - "LoadOrder": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:loadOrder", - "Next": "ReserveInventory" - }, - "ReserveInventory": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:reserveInventory", - "Next": "ChargePayment" - }, - "ChargePayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:chargePayment", - "End": true - } - } +| State machine (ASL JSON) | `"use workflow"` function | The workflow function is the state machine. | +| Task state / Lambda | `"use step"` function | Side effects go in steps. No separate Lambda. | +| Choice state | `if` / `else` / `switch` | Native TypeScript control flow. | +| Wait state | `sleep()` | Import `sleep` from `workflow`. | +| Parallel state | `Promise.all()` | Standard concurrency primitives. | +| Map state | Loop + `Promise.all()` or child workflows | Iterate with `for`/`map`. | +| Retry / Catch | Step retries, `RetryableError`, `FatalError` | Retry logic moves to step boundaries. | +| `.waitForTaskToken` | `createHook()` or `createWebhook()` | Hooks for typed signals; webhooks for HTTP. | +| Child state machine (`StartExecution`) | `"use step"` around `start()` / `getRun()` | Return the `Run` object, await its result from another step. | +| Execution event history | Workflow event log | Same durable replay model. | +| Progress via DynamoDB / SNS for client polling | `getWritable()` + named streams | Stream durable updates; clients read from the stream. | + + +`.waitForTaskToken` becomes `createHook()` or `createWebhook()`. Choice states become `if`/`else`. Map states become `Promise.all()`. Retry policies move from per-state configuration to step-level defaults. + + +## Translate your first workflow + +Start with a single Task state. In ASL, even "call one Lambda" requires a state machine shell: + +```json title="stateMachine.asl.json (Step Functions)" +"LoadOrder": { + "Type": "Task", + "Resource": "arn:aws:states:::lambda:invoke", + "Parameters": { "FunctionName": "loadOrder", "Payload.$": "$" }, + "End": true } ``` -Plus three separate Lambda functions: +```typescript title="workflow/workflows/order.ts (Workflow SDK)" +export async function processOrder(orderId: string) { + 'use workflow'; // [!code highlight] + return await loadOrder(orderId); +} -{/* @skip-typecheck */} -```typescript -// lambda/loadOrder.ts -export const handler = async (event: { orderId: string }) => { - const res = await fetch( - `https://example.com/api/orders/${event.orderId}` - ); +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 }>; -}; +} +``` -// lambda/reserveInventory.ts -export const handler = async (event: { id: string }) => { - await fetch(`https://example.com/api/orders/${event.id}/reserve`, { - method: 'POST', - }); - return event; -}; +What changed: the ASL state machine and its Lambda collapse into two directive-tagged functions in one file. -// lambda/chargePayment.ts -export const handler = async (event: { id: string }) => { - await fetch(`https://example.com/api/orders/${event.id}/charge`, { - method: 'POST', - }); - return { orderId: event.id, status: 'completed' }; -}; -``` +### Adding a second step -### Workflow SDK +In ASL, a second Task means a new state and a `"Next"` transition. In the Workflow SDK, it's another `await`: ```typescript -// workflow/workflows/order.ts export async function processOrder(orderId: string) { 'use workflow'; - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + await reserveInventory(order.id); // [!code highlight] + return { orderId: order.id, status: 'reserved' }; } +``` -async function loadOrder(orderId: string) { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - return res.json() as Promise<{ id: string }>; -} +`await` replaces `"Next"`. Each new step is a new function with `"use step"`; no additional deployment. -async function reserveInventory(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/reserve`, { - method: 'POST', - }); -} +### Starting from an API route -async function chargePayment(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/charge`, { - method: 'POST', - }); -} -``` +Step Functions starts a run via `StartExecution` (AWS SDK or API Gateway integration). The Workflow SDK starts a run with `start()` from a route handler: -The JSON state machine, three Lambda deployments, and IAM wiring collapse into a single TypeScript file. The orchestration reads as the async control flow it always was — `await` replaces `"Next"` transitions, and each step is a function instead of a separately deployed Lambda. - -## Side-by-side: waiting for external approval - -### AWS Step Functions - -```json -{ - "StartAt": "WaitForApproval", - "States": { - "WaitForApproval": { - "Type": "Task", - "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", - "Parameters": { - "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789/approvals", - "MessageBody": { - "refundId.$": "$.refundId", - "taskToken.$": "$$.Task.Token" - } - }, - "Next": "CheckApproval" - }, - "CheckApproval": { - "Type": "Choice", - "Choices": [ - { - "Variable": "$.approved", - "BooleanEquals": true, - "Next": "Approved" - } - ], - "Default": "Rejected" - }, - "Approved": { - "Type": "Pass", - "Result": { "status": "approved" }, - "End": true - }, - "Rejected": { - "Type": "Pass", - "Result": { "status": "rejected" }, - "End": true - } - } +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflows/order'; + +export async function POST(request: Request) { + const { orderId } = (await request.json()) as { orderId: string }; + const run = await start(processOrder, [orderId]); // [!code highlight] + return Response.json({ runId: run.runId }); } ``` -The callback handler calls `SendTaskSuccess` with the task token: +## Wait for an external signal -```typescript -// lambda/approveRefund.ts -import { SFNClient, SendTaskSuccessCommand } from '@aws-sdk/client-sfn'; - -const sfn = new SFNClient({}); - -export const handler = async (event: { - taskToken: string; - approved: boolean; -}) => { - await sfn.send( - new SendTaskSuccessCommand({ - taskToken: event.taskToken, - output: JSON.stringify({ approved: event.approved }), - }) - ); -}; -``` +The minimal ASL for a callback is a Task with `.waitForTaskToken`: -### Workflow SDK +```json title="approval.asl.json (Step Functions)" +"WaitForApproval": { + "Type": "Task", + "Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken", + "Parameters": { + "QueueUrl": "https://sqs.us-east-1.amazonaws.com/123456789012/approvals", + "MessageBody": { + "refundId.$": "$.refundId", + "TaskToken.$": "$$.Task.Token" + } + }, + "End": true +} +``` -```typescript -// workflow/workflows/refund.ts +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" import { createHook } from 'workflow'; export async function refundWorkflow(refundId: string) { 'use workflow'; - - using approval = createHook<{ approved: boolean }>({ + using approval = createHook<{ approved: boolean }>({ // [!code highlight] token: `refund:${refundId}:approval`, }); - - const { approved } = await approval; - - if (!approved) { - return { refundId, status: 'rejected' }; - } - - return { refundId, status: 'approved' }; + return await approval; } ``` -### Resuming the hook from an API route +What changed: no SQS queue, no task token, no callback Lambda. The hook suspends the workflow durably until it is resumed. -```typescript -// app/api/refunds/[refundId]/approve/route.ts -import { resumeHook } from 'workflow/api'; +### Resuming the hook -export async function POST( - request: Request, - { params }: { params: Promise<{ refundId: string }> } -) { - const { refundId } = await params; - const body = (await request.json()) as { approved: boolean }; +Step Functions resumes by calling `SendTaskSuccess` with the task token. The Workflow SDK resumes by calling `resumeHook` with the hook's token: - await resumeHook(`refund:${refundId}:approval`, { - approved: body.approved, - }); +```typescript title="app/api/refunds/[refundId]/approve/route.ts" +import { resumeHook } from 'workflow/api'; +export async function POST(req: Request, { params }: { params: Promise<{ refundId: string }> }) { + const { refundId } = await params; + const { approved } = (await req.json()) as { approved: boolean }; + await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight] return Response.json({ ok: true }); } ``` -Step Functions' `.waitForTaskToken` pattern requires SQS (or another service) to deliver the task token, a separate Lambda to call `SendTaskSuccess`/`SendTaskFailure`, and a Choice state to branch on the result. With Workflow, `createHook()` suspends durably until resumed — no queue, no task token plumbing, no separate callback handler. +### Branching on the result -### Child workflows: keep `start()` and `getRun()` in steps +In ASL, branching after the wait requires a Choice state. In TypeScript, it's just `if`/`else`: -When you need an independent child run, the important migration detail is the **step boundary**. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass serializable `runId` values through the workflow: +```json title="approval.asl.json (Step Functions)" +"CheckApproval": { + "Type": "Choice", + "Choices": [ + { "Variable": "$.approved", "BooleanEquals": true, "Next": "Approved" } + ], + "Default": "Rejected" +} +``` -```typescript -import { getRun, start } from 'workflow/api'; +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" +const { approved } = await approval; +if (approved) return { refundId, status: 'approved' }; // [!code highlight] +return { refundId, status: 'rejected' }; +``` -async function processItem(item: string): Promise { - 'use step'; - return `processed-${item}`; -} +## Spawn a child workflow -export async function childWorkflow(item: string) { - 'use workflow'; - const result = await processItem(item); - return { item, result }; -} +In ASL, a parent machine calls `StartExecution` (usually via `.sync` or `.waitForTaskToken`) to launch a child. In the Workflow SDK, `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Returning the `Run` object from the spawn step lets workflow observability deep-link to the child run. -async function spawnChild(item: string): Promise { - 'use step'; - const run = await start(childWorkflow, [item]); - return run.runId; -} +### Parent starts a child -async function collectResult( - runId: string -): Promise<{ item: string; result: string }> { - 'use step'; - const run = getRun(runId); - const value = await run.returnValue; - return value as { item: string; result: string }; +```typescript title="workflow/workflows/parent.ts" +import { start } from 'workflow/api'; + +async function spawnChild(item: string) { + 'use step'; // [!code highlight] + return start(childWorkflow, [item]); } export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - const result = await collectResult(runId); - return { childRunId: runId, result }; + const run = await spawnChild(item); + return { childRunId: run.runId }; } ``` -## End-to-end migration: order processing saga - -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. - -### AWS Step Functions version - -A Step Functions saga requires explicit Catch blocks on each state to trigger compensation states, resulting in a large ASL graph: - -```json -{ - "StartAt": "LoadOrder", - "States": { - "LoadOrder": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:loadOrder", - "Next": "ReserveInventory", - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "FailOrder" }] - }, - "ReserveInventory": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:reserveInventory", - "Next": "ChargePayment", - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "ReleaseInventory" }] - }, - "ChargePayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:chargePayment", - "Next": "CreateShipment", - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "RefundPayment" }] - }, - "CreateShipment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:createShipment", - "End": true, - "Retry": [{ "ErrorEquals": ["States.ALL"], "MaxAttempts": 3 }], - "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "CancelShipmentCompensation" }] - }, - "CancelShipmentCompensation": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:cancelShipment", - "Next": "RefundPayment" - }, - "RefundPayment": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:refundPayment", - "Next": "ReleaseInventory" - }, - "ReleaseInventory": { - "Type": "Task", - "Resource": "arn:aws:lambda:us-east-1:123456789:function:releaseInventory", - "Next": "FailOrder" - }, - "FailOrder": { - "Type": "Fail", - "Error": "OrderProcessingFailed" - } - } +### Awaiting the child's result + +Add a second step that wraps `getRun()` and awaits `returnValue`: + +```typescript +import { getRun } from 'workflow/api'; + +async function collectResult(runId: string) { + 'use step'; // [!code highlight] + const run = getRun(runId); + return (await run.returnValue) as { item: string; result: string }; } ``` -Each Task state maps to a separate Lambda function, and the compensation chain (CancelShipment → RefundPayment → ReleaseInventory → Fail) must be wired explicitly in the state machine. +Then in the workflow: `const result = await collectResult(run.runId);`. The child workflow itself (`childWorkflow`) is defined elsewhere with `"use workflow"`. -### Workflow SDK version +## What you stop operating -```typescript -import { FatalError, getStepMetadata, getWritable } from 'workflow'; +Moving off Step Functions removes these surfaces from the application: -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; +- ASL state machine JSON and its reference syntax. +- Per-task Lambda functions, their IAM roles, and CloudFormation/CDK wiring. +- Task-token delivery infrastructure (SQS queues, callback Lambdas). +- Separate progress channels (DynamoDB, SNS) for client-visible updates. +- CloudWatch and X-Ray configuration for orchestrator observability. -export async function processOrderSaga(orderId: string) { - 'use workflow'; +Workflow and step functions live in the same deployment as the application. State transitions are `await` calls. Progress streaming, retries, and observability are built in. - const rollbacks: Array<() => Promise> = []; - - try { - const order = await loadOrder(orderId); - await emitProgress({ stage: 'loaded', orderId: order.id }); - - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - await emitProgress({ stage: 'inventory_reserved', orderId: order.id }); - - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - await emitProgress({ stage: 'payment_captured', orderId: order.id }); - - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - await emitProgress({ stage: 'shipment_created', orderId: order.id }); - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); - } - throw error; - } -} +## Step-by-step first migration -async function loadOrder(orderId: string): Promise { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - if (!res.ok) throw new FatalError('Order not found'); - return res.json() as Promise; -} +Pick one state machine and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. -async function reserveInventory(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; -} +### Step 1: Install the Workflow SDK -async function releaseInventory(reservationId: string): Promise { - 'use step'; - await fetch( - `https://example.com/api/inventory/reservations/${reservationId}`, - { method: 'DELETE' } - ); -} +Add the `workflow` runtime package. -async function chargePayment(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/payments/charges', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - }); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; -} +```bash +pnpm add workflow +``` -async function refundPayment(chargeId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { - method: 'POST', - }); -} +### Step 2: Rewrite the state machine as a `"use workflow"` function -async function createShipment(order: Order): Promise { - 'use step'; - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; -} +Transitions become `await` calls. Control flow (`Choice`, `Wait`, `Parallel`, `Map`) becomes `if`/`switch`, `sleep`, `Promise.all`, and loops. -async function cancelShipment(shipmentId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/shipments/${shipmentId}`, { - method: 'DELETE', - }); +```ts title="workflows/order.ts" +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + const order = await loadOrder(orderId); + if (order.total > 1000) await reviewManually(order); + await chargePayment(order); } +``` + +### Step 3: Move each Lambda into a step function -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writable = getWritable<{ stage: string; orderId: string }>(); - const writer = writable.getWriter(); - try { - await writer.write(update); - } finally { - writer.releaseLock(); - } +Inline the Lambda body into a function with `"use step"` on the first line. Step functions keep full Node.js access, so existing SDK calls work unchanged. + +```ts +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); } ``` -- Step Functions compensation requires explicit Catch-to-compensation-state wiring for every state in the graph. With Workflow, a rollback stack in the workflow function handles compensation for any number of steps without duplicating graph nodes. -- Use `getStepMetadata().stepId` as the idempotency key for payment and inventory APIs — no manual token management required. -- Stream user-visible progress from steps with `getWritable()` instead of adding a separate progress transport. Step Functions has no built-in equivalent; you would typically write to DynamoDB or SNS and poll from the client. +### Step 4: Replace `.waitForTaskToken` with a hook + +Swap the task-token callback Lambda for `createHook()`. Callers `resumeHook(token, payload)` instead of `SendTaskSuccess`. + +### Step 5: Start runs from an API route -## Why teams usually simplify infrastructure in this move +Delete the `StartExecution` call and IAM wiring. Launch runs directly from a route handler: -Step Functions requires you to define state machines in JSON (Amazon States Language), deploy each task as a separate Lambda function, manage IAM roles between the orchestrator and every Lambda, and configure CloudWatch, X-Ray, or third-party tooling for observability. These are powerful primitives when you need visual workflow editing or cross-service AWS orchestration — but for TypeScript-first teams, they are overhead without a corresponding benefit. +```ts title="app/api/orders/route.ts" +import { start } from "workflow/api"; +import { processOrder } from "@/workflows/order"; -With the Workflow SDK: +export async function POST(req: Request) { + const { orderId } = await req.json(); + const run = await start(processOrder, [orderId]); + return Response.json({ runId: run.runId }); +} +``` -- **No state machine JSON.** The workflow function _is_ the state machine. Branching, looping, and error handling are TypeScript — not a JSON DSL with its own type system and reference syntax. -- **No Lambda function wiring.** Steps run in the same deployment as your app. There are no separate functions to deploy, version, or connect with IAM policies. -- **No infrastructure to provision.** There is no CloudFormation/CDK stack for the orchestrator, no Lambda concurrency limits to tune, and no pricing tiers to navigate. -- **TypeScript all the way down.** Workflow and step functions are regular TypeScript with directive annotations. State transitions are `await` calls, not `"Next"` pointers. -- **Durable streaming built in.** `getWritable()` lets you push progress updates from steps without adding DynamoDB, SNS, or a WebSocket API Gateway. -- **Efficient resource usage.** When a workflow is suspended on `sleep()` or a hook, it pauses cleanly instead of keeping a worker process alive. +### Step 6: Retire the Step Functions infrastructure -This is not about replacing every Step Functions feature. It is about recognizing that most TypeScript teams use a fraction of Step Functions' state-language surface and pay for the rest in configuration complexity and AWS service sprawl. +Delete the ASL JSON, per-task Lambda deployments, IAM roles, and callback queues. Remove CloudWatch and X-Ray wiring used for orchestrator observability. Verify the run in `npx workflow web` before shipping. ## Quick-start checklist -- Replace your ASL state machine JSON with a single `"use workflow"` function. State transitions become `await` calls. -- Convert each Task state / Lambda function into a `"use step"` function in the same file. -- Replace Choice states with `if`/`else`/`switch` in your workflow function. -- Replace Wait states with `sleep()` imported from `workflow`. -- Replace Parallel states with `Promise.all()` over concurrent step calls. -- Replace Map states with loops or `Promise.all()` over arrays, using `"use step"` wrappers around `start()` for large fan-outs. -- Replace child state machines (`StartExecution`) with `"use step"` wrappers around `start()` and `getRun()` when you need independent child runs. -- Replace `.waitForTaskToken` callback patterns with `createHook()` or `createWebhook()` depending on whether the caller is internal or HTTP-based. -- Move Retry/Catch configuration down to step boundaries using default retries, `maxRetries`, `RetryableError`, and `FatalError`, with standard `try`/`catch` for error handling. -- Add idempotency keys to external side effects using `getStepMetadata().stepId`. -- Stream user-visible progress from steps with `getWritable()` when you previously polled DynamoDB or used SNS notifications. -- Deploy your app and verify workflows run end-to-end with built-in observability. +- Replace the ASL state machine with a single `"use workflow"` function. Transitions become `await` calls. +- Convert each Task / Lambda into a `"use step"` function in the same file. +- Replace Choice states with `if`/`else`/`switch`. +- Replace Wait states with `sleep()` from `workflow`. +- Replace Parallel states with `Promise.all()`. +- Replace Map states with loops or `Promise.all()`. For large fan-outs, wrap `start()` in a step. +- Replace `StartExecution` child machines with `"use step"` wrappers around `start()` and `getRun()`. +- Replace `.waitForTaskToken` with `createHook()` (internal callers) or `createWebhook()` (HTTP callers). +- Move Retry/Catch to step boundaries using `maxRetries`, `RetryableError`, and `FatalError`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Stream progress from steps with `getWritable()` instead of polling DynamoDB or SNS. +- Deploy and verify runs end-to-end with built-in observability. diff --git a/docs/content/docs/migration-guides/migrating-from-inngest.mdx b/docs/content/docs/migration-guides/migrating-from-inngest.mdx index 4da3d12c51..300c978cd9 100644 --- a/docs/content/docs/migration-guides/migrating-from-inngest.mdx +++ b/docs/content/docs/migration-guides/migrating-from-inngest.mdx @@ -2,7 +2,7 @@ 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 and a realistic order-processing saga. +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 @@ -14,530 +14,264 @@ related: - /docs/deploying/world/vercel-world --- -## What changes when you leave Inngest? + +Install the Workflow SDK migration skill: -With Inngest, you define functions using `inngest.createFunction()`, register them through a `serve()` handler, and break work into steps with `step.run()`, `step.sleep()`, and `step.waitForEvent()`. The Inngest platform manages event routing, step execution, and retries on its infrastructure. +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + +## 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. -With the Workflow SDK, you write `"use workflow"` functions that orchestrate `"use step"` functions — all in the same file, all plain TypeScript. There is no separate function registry, no event-driven dispatch layer, and no SDK client to configure. Durable replay, automatic retries, and step-level persistence still exist — they are built into the runtime. +## What changes when you leave Inngest -The migration path is mostly about **collapsing the SDK abstraction** and **writing plain async functions**, not rewriting business logic. +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()` | The workflow function itself is the entry point — no wrapper needed. | -| `step.run()` | `"use step"` function | Each step is a standalone async function with full Node.js access. | -| `step.sleep()` / `step.sleepUntil()` | `sleep()` | Import `sleep` from `workflow` and call it in your workflow function. | -| `step.waitForEvent()` | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| `step.invoke()` | `"use step"` wrappers around `start()` / `getRun()` | Start another workflow from a step, return its `runId`, then await the child result from another step. | -| `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly instead of routing through an event bus. | -| Retry configuration (`retries`) | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retry logic moves down to the step level. | -| `step.sendEvent()` | `"use step"` wrapper around `start()` | Fan out to other workflows from a step instead of emitting onto an event bus. | -| Realtime / `step.realtime.publish()` | `getWritable()` | Stream progress directly from steps with built-in durable streaming. | - -## Side-by-side: hello workflow +| `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. | -### Inngest +## Translate your first workflow -```typescript -// inngest/functions/order.ts -import { inngest } from '../client'; +Start with the shell of a function. Inngest wraps it in `createFunction`; Workflow SDK marks it with a directive. +```typescript title="inngest/functions/order.ts" export const processOrder = inngest.createFunction( - { id: 'process-order' }, - { event: 'order/created' }, + { + id: 'process-order', + triggers: [{ event: 'order/created' }], + }, async ({ event, step }) => { - const order = await step.run('load-order', async () => { - const res = await fetch( - `https://example.com/api/orders/${event.data.orderId}` - ); - return res.json() as Promise<{ id: string }>; - }); - - await step.run('reserve-inventory', async () => { - await fetch(`https://example.com/api/orders/${order.id}/reserve`, { - method: 'POST', - }); - }); - - await step.run('charge-payment', async () => { - await fetch(`https://example.com/api/orders/${order.id}/charge`, { - method: 'POST', - }); - }); - - return { orderId: order.id, status: 'completed' }; + return { orderId: event.data.orderId, status: 'completed' }; } ); ``` -### Workflow SDK - -```typescript -// workflow/workflows/order.ts +```typescript title="workflow/workflows/order.ts" export async function processOrder(orderId: string) { - 'use workflow'; - - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + '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'; + 'use step'; // [!code highlight] const res = await fetch(`https://example.com/api/orders/${orderId}`); return res.json() as Promise<{ id: string }>; } - -async function reserveInventory(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/reserve`, { - method: 'POST', - }); -} - -async function chargePayment(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/charge`, { - method: 'POST', - }); -} ``` -The biggest change is replacing `step.run()` closures with named `"use step"` functions. Each step becomes a regular async function instead of an inline callback — easier to test, easier to reuse, and the orchestration reads as plain TypeScript. +Call it from the workflow like any async function: `const order = await loadOrder(orderId)`. Additional side effects (`reserveInventory`, `chargePayment`) follow the same shape. -## Side-by-side: waiting for external approval +### Start the run -### Inngest +Inngest dispatches via `inngest.send({ name: 'order/created', data: { orderId } })`. Workflow SDK launches directly: -```typescript -// inngest/functions/refund.ts -import { inngest } from '../client'; +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflow/workflows/order'; -export const refundWorkflow = inngest.createFunction( - { id: 'refund-workflow' }, - { event: 'refund/requested' }, - async ({ event, step }) => { - const refundId = event.data.refundId; - - const approval = await step.waitForEvent('wait-for-approval', { - event: 'refund/approved', - match: 'data.refundId', - timeout: '7d', - }); - - if (!approval) { - return { refundId, status: 'timed-out' }; - } - - if (!approval.data.approved) { - return { refundId, status: 'rejected' }; - } - - return { refundId, status: 'approved' }; - } -); +const run = await start(processOrder, [orderId]); // [!code highlight] ``` -### Workflow SDK +No event bus, no registry. `start()` returns a handle immediately. -```typescript -// workflow/workflows/refund.ts -import { createHook, sleep } from 'workflow'; +## Wait for an external signal -type ApprovalResult = - | { type: 'decision'; approved: boolean } - | { type: 'timeout'; approved: false }; +`step.waitForEvent()` becomes `createHook()` plus `await`. -export async function refundWorkflow(refundId: string) { - 'use workflow'; +```typescript title="workflow/workflows/refund.ts (Inngest)" +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 result: ApprovalResult = await Promise.race([ - approval.then((payload) => ({ - type: 'decision' as const, - approved: payload.approved, - })), - sleep('7d').then(() => ({ - type: 'timeout' as const, - approved: false as const, - })), - ]); - - if (result.type === 'timeout') { - return { refundId, status: 'timed-out' }; - } +```typescript title="workflow/workflows/refund.ts (Workflow SDK)" +using approval = createHook<{ approved: boolean }>({ // [!code highlight] + token: `refund:${refundId}:approval`, +}); +const payload = await approval; +``` - if (!result.approved) { - return { refundId, status: 'rejected' }; - } +**What changed:** event name + match expression collapse into a single `token` string. The caller supplies that token directly, with no event schema. - return { refundId, status: 'approved' }; -} -``` +### Resume from an API route -### Resuming the hook from an API route +Inngest resumes with `inngest.send({ name: 'refund/approved', data: { refundId, approved } })`. The SDK equivalent is `resumeHook`: -```typescript -// app/api/refunds/[refundId]/approve/route.ts +```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 }> } -) { +export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) { const { refundId } = await params; - const body = (await request.json()) as { approved: boolean }; - - await resumeHook(`refund:${refundId}:approval`, { - approved: body.approved, - }); - + const { approved } = (await request.json()) as { approved: boolean }; + await resumeHook(`refund:${refundId}:approval`, { approved }); // [!code highlight] return Response.json({ ok: true }); } ``` -Inngest's `step.waitForEvent()` with event matching maps to `createHook()`, and the `timeout: '7d'` behavior maps to `sleep('7d')` combined with `Promise.race()`. The workflow still suspends durably in both branches — there is no event bus or matching expression, but the timeout is modeled explicitly. +### Add a timeout and branch on the payload -### Child workflows: keep `start()` and `getRun()` in steps +Inngest's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`: -When you need an independent child run, the important migration detail is the **step boundary**. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass serializable `runId` values through the workflow: +```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] +]); -```typescript -import { getRun, start } from 'workflow/api'; +if (result.type === 'timeout') return { refundId, status: 'timed-out' }; +if (!result.approved) return { refundId, status: 'rejected' }; +return { refundId, status: 'approved' }; +``` -async function processItem(item: string): Promise { - 'use step'; - return `processed-${item}`; -} + +Event matching disappears. A hook's token encodes the routing (for example, `refund:${refundId}:approval`), and the caller supplies that token to `resumeHook()`. + -export async function childWorkflow(item: string) { - 'use workflow'; - const result = await processItem(item); - return { item, result }; -} +## Spawn a child workflow -async function spawnChild(item: string): Promise { +`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. + +```typescript title="workflow/workflows/parent.ts" +async function spawnChild(item: string) { 'use step'; - const run = await start(childWorkflow, [item]); - return run.runId; + return start(childWorkflow, [item]); // [!code highlight] } +``` + +Await the result in a second step, then orchestrate both from the parent: -async function collectResult( - runId: string -): Promise<{ item: string; result: string }> { +```typescript title="workflow/workflows/parent.ts" +async function collectResult(runId: string) { 'use step'; const run = getRun(runId); - const value = await run.returnValue; - return value as { item: string; result: string }; + return await run.returnValue; // [!code highlight] } export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - const result = await collectResult(runId); - return { childRunId: runId, result }; + const child = await spawnChild(item); + return await collectResult(child.runId); } ``` -## End-to-end migration: order processing saga - -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. - -### Inngest version +## What you stop operating -```typescript -// inngest/functions/order-saga.ts -import { inngest } from '../client'; +Dropping the Inngest SDK removes several moving parts: -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; +- **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. -export const processOrderSaga = inngest.createFunction( - { id: 'process-order-saga', retries: 3 }, - { event: 'order/process' }, - async ({ event, step }) => { - const orderId = event.data.orderId; - - const order = await step.run('load-order', async () => { - const res = await fetch( - `https://example.com/api/orders/${orderId}` - ); - if (!res.ok) throw new Error('Order not found'); - return res.json() as Promise; - }); - - const inventory = await step.run('reserve-inventory', async () => { - const res = await fetch( - 'https://example.com/api/inventory/reservations', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - } - ); - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; - }); - - let payment: Charge; - try { - payment = await step.run('charge-payment', async () => { - const res = await fetch( - 'https://example.com/api/payments/charges', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - } - ); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; - }); - } catch (error) { - await step.run('release-inventory', async () => { - await fetch( - `https://example.com/api/inventory/reservations/${inventory.reservationId}`, - { method: 'DELETE' } - ); - }); - throw error; - } - - let shipment: Shipment; - try { - shipment = await step.run('create-shipment', async () => { - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; - }); - } catch (error) { - await step.run('refund-payment', async () => { - await fetch( - `https://example.com/api/payments/charges/${payment.chargeId}/refund`, - { method: 'POST' } - ); - }); - await step.run('release-inventory-after-refund', async () => { - await fetch( - `https://example.com/api/inventory/reservations/${inventory.reservationId}`, - { method: 'DELETE' } - ); - }); - throw error; - } - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } -); -``` +## Step-by-step first migration -**Sample input:** +Pick one Inngest function and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. -```json -{ - "event": { - "data": { - "orderId": "ord_123" - } - } -} -``` +### Step 1: Install the Workflow SDK -**Expected output:** +Add the runtime and the framework integration that matches the app. -```json -{ - "orderId": "ord_123", - "reservationId": "res_456", - "chargeId": "ch_789", - "shipmentId": "shp_101", - "status": "completed" -} +```bash +pnpm add workflow ``` -### Workflow SDK version +### Step 2: Convert `createFunction` to a `"use workflow"` export -```typescript -import { FatalError, getStepMetadata, getWritable } from 'workflow'; +Replace the factory call with a plain async export. Move the handler body up. The event-binding argument goes away. -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; - -export async function processOrderSaga(orderId: string) { - 'use workflow'; +```ts title="workflows/order.ts" +// Before (Inngest) +// export const processOrder = inngest.createFunction( +// { id: "process-order", triggers: [{ event: "order.created" }] }, +// async ({ event, step }) => { ... } +// ); - const rollbacks: Array<() => Promise> = []; - - try { - const order = await loadOrder(orderId); - await emitProgress({ stage: 'loaded', orderId: order.id }); - - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - await emitProgress({ stage: 'inventory_reserved', orderId: order.id }); - - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - await emitProgress({ stage: 'payment_captured', orderId: order.id }); - - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - await emitProgress({ stage: 'shipment_created', orderId: order.id }); - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); - } - throw error; - } +// After (Workflow SDK) +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + // ... } +``` -async function loadOrder(orderId: string): Promise { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - if (!res.ok) throw new FatalError('Order not found'); - return res.json() as Promise; -} +### Step 3: Convert `step.run` callbacks into named step functions -async function reserveInventory(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; -} +Each inline callback becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. -async function releaseInventory(reservationId: string): Promise { - 'use step'; - await fetch( - `https://example.com/api/inventory/reservations/${reservationId}`, - { method: 'DELETE' } - ); +```ts +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); } +``` -async function chargePayment(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/payments/charges', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - }); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; -} +### Step 4: Replace `waitForEvent`, `sleep`, and `invoke` -async function refundPayment(chargeId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { - method: 'POST', - }); -} +- `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`. -async function createShipment(order: Order): Promise { - 'use step'; - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; -} +### Step 5: Start runs from the app -async function cancelShipment(shipmentId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/shipments/${shipmentId}`, { - method: 'DELETE', - }); -} +Delete the `serve()` handler and event dispatch. Launch runs directly from an API route: -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writable = getWritable<{ stage: string; orderId: string }>(); - const writer = writable.getWriter(); - try { - await writer.write(update); - } finally { - writer.releaseLock(); - } +```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 }); } ``` -- Inngest's per-step compensation (`step.run` for rollback after a failed step) maps cleanly to a rollback stack in the workflow function. The rollback pattern scales to any number of steps without nested try/catch blocks. -- Use `getStepMetadata().stepId` as the idempotency key for payment and inventory APIs — no manual step naming required. -- Stream user-visible progress from steps with `getWritable()` instead of Inngest Realtime's `step.realtime.publish()`. The stream is durable and built into the workflow runtime. - -## Why teams usually simplify infrastructure in this move - -Inngest adds an event-driven orchestration layer between your app and your durable logic. You configure an Inngest client, register functions through a `serve()` handler, route work through events, and manage step execution through the Inngest platform. This is a clean model when you want event-driven fan-out across many loosely coupled functions — but for TypeScript teams, the indirection often outweighs the benefit. - -With the Workflow SDK: - -- **No SDK client or serve handler.** Workflow functions are regular TypeScript files with directive annotations. There is no client to configure, no function registry to maintain, and no serve endpoint to wire up. -- **No event bus.** Start workflows directly with `start()` from your API routes, server actions, or app boundary. You do not need to define event schemas or route through a dispatch layer. -- **TypeScript all the way down.** Steps are named async functions, not inline closures passed to `step.run()`. They are easier to test, type, and reuse. -- **Durable streaming built in.** `getWritable()` lets you push progress updates from steps without adding Inngest Realtime or a separate WebSocket/SSE transport. -- **Efficient resource usage.** When a workflow is suspended on `sleep()` or a hook, it pauses cleanly instead of keeping a worker process alive. +### Step 6: Retire the Inngest infrastructure -This is not about replacing every Inngest feature. It is about recognizing that most TypeScript teams use a fraction of Inngest's event-routing surface and pay for the rest in SDK complexity and platform coupling. +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 and start it with `start()` from your app boundary. +- Replace `inngest.createFunction()` with a `"use workflow"` function; launch it with `start()`. - Convert each `step.run()` callback into a named `"use step"` function. -- Replace `step.sleep()` / `step.sleepUntil()` with `sleep()` imported from `workflow`. -- Replace `step.waitForEvent()` with `createHook()` or `createWebhook()` depending on whether the caller is internal or HTTP-based. -- When `step.waitForEvent()` includes a timeout, map it to `createHook()` or `createWebhook()` plus `sleep()` and `Promise.race()`. -- Replace `step.invoke()` with `"use step"` wrappers around `start()` and `getRun()` when you need a child workflow with an independent run. -- Replace `step.sendEvent()` fan-out with `start()` called from a `"use step"` function when the fan-out originates inside a workflow. -- Remove the Inngest client, `serve()` handler, and event definitions from your app. -- Move retry configuration down to step boundaries using default retries, `maxRetries`, `RetryableError`, and `FatalError`. -- Add idempotency keys to external side effects using `getStepMetadata().stepId`. -- Replace `step.realtime.publish()` with `getWritable()` for streaming progress to clients. -- Deploy your app and verify workflows run end-to-end with built-in observability. +- 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. diff --git a/docs/content/docs/migration-guides/migrating-from-temporal.mdx b/docs/content/docs/migration-guides/migrating-from-temporal.mdx index 608ee9b895..1a875d0f10 100644 --- a/docs/content/docs/migration-guides/migrating-from-temporal.mdx +++ b/docs/content/docs/migration-guides/migrating-from-temporal.mdx @@ -2,7 +2,7 @@ title: Migrating from Temporal description: Move a Temporal TypeScript workflow to the Workflow SDK by replacing Activities, Workers, Signals, and Child Workflows with Workflows, Steps, Hooks, and start()/getRun(). type: guide -summary: Translate a Temporal app into the Workflow SDK with side-by-side code and a realistic order-processing saga. +summary: Translate a Temporal app into the Workflow SDK with side-by-side code examples. prerequisites: - /docs/getting-started/next - /docs/foundations/workflows-and-steps @@ -14,13 +14,29 @@ related: - /docs/deploying/world/vercel-world --- -## What changes when you leave Temporal? + +Install the Workflow SDK migration skill: -With Temporal, you operate a control plane (Temporal Server or Cloud), deploy and maintain a Worker fleet, define Activities in a separate module, wire them through `proxyActivities`, and manage Task Queues. Your workflow code is durable, but the infrastructure around it is not trivial. +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + +## Why migrate to the Workflow SDK + +- Streaming is built in. Durable progress writes go to named streams via `getWritable({ namespace })`, and clients read them directly. No separate WebSocket, SSE, or progress-polling layer to operate. +- Infrastructure and orchestration live in a single deployment. There is no separate Worker fleet or Temporal Server to run. The runtime, step logic, and orchestration share the app's observability and log aggregation. +- TypeScript-first developer experience. Workflows and steps live in the same file with plain `await` control flow, `try/catch`, and `Promise.all`. +- Agent-first tooling. A first-class CLI (`npx workflow`), `@workflow/ai` integration for durable AI agents, and a bundled Claude skill for AI-assisted authoring. +- Per-step retry controls. `RetryableError`, `FatalError`, and `maxRetries` live at the step boundary instead of an Activity-level retry policy configured elsewhere. -With the Workflow SDK, the runtime is managed for you. You write `"use workflow"` functions that orchestrate `"use step"` functions — all in the same file, all plain TypeScript. There are no Workers to poll, no Task Queues to configure, and no separate Activity modules to maintain. Durable replay, automatic retries, and event history still exist — they just happen behind the scenes. +## What changes when you leave Temporal -The migration path is mostly about **removing infrastructure** and **collapsing indirection**, not rewriting business logic. +Temporal requires operating a control plane (Temporal Server or Cloud), a Worker fleet, Activity modules wired through `proxyActivities`, and Task Queues. The workflow code is durable; the surrounding infrastructure is substantial. + +The Workflow SDK runs on managed infrastructure. Write `"use workflow"` functions that orchestrate `"use step"` functions in the same file, in plain TypeScript. There are no Workers, Task Queues, or separate Activity modules. Durable replay, automatic retries, and event history are handled by the runtime. + +Migration removes infrastructure and collapses indirection. Business logic stays as regular async TypeScript. ## Concept mapping @@ -30,401 +46,237 @@ The migration path is mostly about **removing infrastructure** and **collapsing | Activity | `"use step"` function | Put side effects and Node.js access in steps. | | Worker + Task Queue | Managed execution | No worker fleet or polling loop to operate. | | Signal | `createHook()` or `createWebhook()` | Use hooks for typed resume signals; webhooks for HTTP callbacks. | -| Query / Update | `getRun()` + app API, or hook-driven mutation + returned state | Usually model reads through your app and writes through hooks/webhooks. | -| Child Workflow | `"use step"` wrappers around `start()` / `getRun()` | Spawn children from a step, return serializable `runId` values, then poll or await child results from another step. | -| Activity retry policy | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retry logic moves down to the step level. | -| Event History | Workflow event log / run timeline | Same durable replay idea, fewer surfaces to manage directly. | +| Query | `getWritable({ namespace: 'status' })` stream | Durably stream status updates from the workflow. Clients read from the stream instead of polling a database. | +| Update | `createHook()` + `resumeHook()` | Writes go through hooks. | +| Child Workflow | `"use step"` wrappers around `start()` / `getRun()` | Spawn from a step and return the `Run` object so observability can deep-link into child runs. | +| Activity retry policy | Step retries, `RetryableError`, `FatalError`, `maxRetries` | Retries live at the step boundary. | +| Event History | Workflow event log / run timeline | Same durable replay, fewer surfaces to manage. | -## Side-by-side: hello workflow +## Translate your first workflow -### Temporal +### Minimal translation -```typescript -// temporal/workflows/order.ts -import * as wf from '@temporalio/workflow'; -import type * as activities from '../activities/order'; +Start with the directive change. The Temporal definition proxies activities through a module; the Workflow SDK version puts the directive inline. -const { loadOrder, reserveInventory, chargePayment } = - wf.proxyActivities({ - startToCloseTimeout: '5 minute', - }); +```typescript title="workflows/order.ts (Temporal)" +const { chargePayment } = wf.proxyActivities({ + startToCloseTimeout: '5 minutes', +}); export async function processOrder(orderId: string) { - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + await chargePayment(orderId); + return { orderId, status: 'completed' }; } ``` -### Workflow SDK - -```typescript -// workflow/workflows/order.ts +```typescript title="workflows/order.ts (Workflow SDK)" export async function processOrder(orderId: string) { - 'use workflow'; - - const order = await loadOrder(orderId); - await reserveInventory(order.id); - await chargePayment(order.id); - return { orderId: order.id, status: 'completed' }; + 'use workflow'; // [!code highlight] + await chargePayment(orderId); + return { orderId, status: 'completed' }; } +``` -async function loadOrder(orderId: string) { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - return res.json() as Promise<{ id: string }>; -} +**What changed:** `proxyActivities` and the activity module disappear. The orchestrator is plain async TypeScript marked with `"use workflow"`. -async function reserveInventory(orderId: string) { - 'use step'; - await fetch(`https://example.com/api/orders/${orderId}/reserve`, { - method: 'POST', - }); -} +### Adding a step + +Side effects move into a colocated `"use step"` function: +```typescript title="workflow/workflows/order.ts" async function chargePayment(orderId: string) { - 'use step'; + 'use step'; // [!code highlight] await fetch(`https://example.com/api/orders/${orderId}/charge`, { method: 'POST', }); } ``` -The biggest code change is not "rewrite the workflow logic." It is "move side effects into `"use step"` functions and stop thinking about workers as application code." The orchestration stays as regular async TypeScript. +Additional steps (`loadOrder`, `reserveInventory`) follow the same shape and can be called from the workflow in sequence. -## Side-by-side: waiting for external approval +### Starting the run -### Temporal +Replace Worker + Task Queue wiring with a single `start()` call from an API route: -```typescript -// temporal/workflows/refund.ts -import * as wf from '@temporalio/workflow'; +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflows/order'; -export const approveRefund = wf.defineSignal<[boolean]>('approveRefund'); +export async function POST(request: Request) { + const { orderId } = (await request.json()) as { orderId: string }; + const run = await start(processOrder, [orderId]); // [!code highlight] + return Response.json({ runId: run.runId }); +} +``` -export async function refundWorkflow(refundId: string) { - let approved: boolean | undefined; +## Wait for an external signal - wf.setHandler(approveRefund, (value) => { - approved = value; - }); +### Minimal translation - await wf.condition(() => approved !== undefined); +Temporal needs a signal definition, a handler, and a `condition()` guard. The Workflow SDK collapses all three into a single `createHook()` + `await`. - if (!approved) { - return { refundId, status: 'rejected' }; - } +```typescript title="temporal/workflows/refund.ts" +export const approveRefund = wf.defineSignal<[boolean]>('approveRefund'); - return { refundId, status: 'approved' }; +export async function refundWorkflow(refundId: string) { + let approved: boolean | undefined; + wf.setHandler(approveRefund, (v) => { approved = v; }); + await wf.condition(() => approved !== undefined); + return { refundId, approved }; } ``` -### Workflow SDK - -```typescript -// workflow/workflows/refund.ts +```typescript title="workflow/workflows/refund.ts" import { createHook } from 'workflow'; export async function refundWorkflow(refundId: string) { 'use workflow'; - using approval = createHook<{ approved: boolean }>({ - token: `refund:${refundId}:approval`, + token: `refund:${refundId}:approval`, // [!code highlight] }); - - const { approved } = await approval; - - if (!approved) { - return { refundId, status: 'rejected' }; - } - - return { refundId, status: 'approved' }; + const { approved } = await approval; // [!code highlight] + return { refundId, approved }; } ``` -### Resuming the hook from an API route +The workflow suspends durably at `await approval` until resumed. No polling, no handler registration. + +### Resuming from an API route + +Any HTTP caller can resume by token: -```typescript -// app/api/refunds/[refundId]/approve/route.ts +```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 }> } -) { +export async function POST(request: Request, { params }: { params: Promise<{ refundId: string }> }) { const { refundId } = await params; const body = (await request.json()) as { approved: boolean }; - - await resumeHook(`refund:${refundId}:approval`, { - approved: body.approved, - }); - + await resumeHook(`refund:${refundId}:approval`, body); // [!code highlight] return Response.json({ ok: true }); } ``` -Temporal's Signal + `condition()` pattern becomes a single `createHook()` call. The workflow suspends durably until the hook is resumed — no polling, no separate signal handler registration. + +Temporal Queries expose in-memory workflow state on demand. In the Workflow SDK, the equivalent is a durable stream: call `getWritable({ namespace: 'status' })` from inside the workflow to write status updates, and have clients read from the end of that stream to get the current state. Hooks are the write channel for resuming a paused workflow with new data. + -### Child workflows: keep `start()` and `getRun()` in steps +## Spawn a child workflow -When you need an independent child run, the important migration detail is the **step boundary**. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions and pass serializable `runId` values through the workflow: +### Minimal translation -```typescript -import { getRun, start } from 'workflow/api'; +`start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the `Run` object (not a plain `runId` string) so workflow observability can deep-link into child runs. -async function processItem(item: string): Promise { - 'use step'; - return `processed-${item}`; -} +```typescript title="workflow/workflows/parent.ts" +import { start } from 'workflow/api'; -export async function childWorkflow(item: string) { - 'use workflow'; - const result = await processItem(item); - return { item, result }; -} - -async function spawnChild(item: string): Promise { - 'use step'; - const run = await start(childWorkflow, [item]); - return run.runId; -} - -async function collectResult( - runId: string -): Promise<{ item: string; result: string }> { - 'use step'; - const run = getRun(runId); - const value = await run.returnValue; - return value as { item: string; result: string }; +async function spawnChild(item: string) { + 'use step'; // [!code highlight] + return start(childWorkflow, [item]); // [!code highlight] } export async function parentWorkflow(item: string) { 'use workflow'; - const runId = await spawnChild(item); - const result = await collectResult(runId); - return { childRunId: runId, result }; + const child = await spawnChild(item); // [!code highlight] + return { childRunId: child.runId }; } ``` -## End-to-end migration: order processing saga +### Awaiting the child's return value -This example exercises compensation (rollbacks), idempotency keys, retry semantics, and progress streaming — the patterns that matter most in a real migration. +A second step fetches the run and awaits `returnValue`: -### Temporal version +```typescript title="workflow/workflows/parent.ts" +import { getRun } from 'workflow/api'; -A Temporal saga requires manual compensation wiring — typically nested try/catch blocks around each Activity call, with explicit rollback Activities in each catch: +async function collectResult(runId: string) { + 'use step'; + const run = getRun(runId); + return (await run.returnValue) as { item: string; result: string }; // [!code highlight] +} +``` -```typescript -// temporal/workflows/order-saga.ts -import * as wf from '@temporalio/workflow'; -import type * as activities from '../activities/order-saga'; +Call both steps from the parent in sequence: `const result = await collectResult(child.runId)`. To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. -const { - loadOrder, - reserveInventory, - releaseInventory, - chargePayment, - refundPayment, - createShipment, - cancelShipment, -} = wf.proxyActivities({ - startToCloseTimeout: '5 minute', - retry: { maximumAttempts: 3 }, -}); + +Activity retry policy moves to the step boundary. Use `maxRetries`, `RetryableError`, and `FatalError` on each step instead of a single workflow-wide retry block. + -export async function processOrderSaga(orderId: string) { - const order = await loadOrder(orderId); +## What you stop operating - const inventory = await reserveInventory(order); - - let payment: { chargeId: string }; - try { - payment = await chargePayment(order); - } catch (error) { - await releaseInventory(inventory.reservationId); - throw error; - } - - let shipment: { shipmentId: string }; - try { - shipment = await createShipment(order); - } catch (error) { - await refundPayment(payment.chargeId); - await releaseInventory(inventory.reservationId); - throw error; - } - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; -} -``` +- **Temporal Server or Cloud.** Durable state lives in the managed event log. +- **Worker fleet.** The runtime schedules execution; workflows run where the app runs. +- **Task Queues.** No queue routing to configure or monitor. +- **Activity modules and `proxyActivities`.** Steps live next to the workflow that calls them. +- **Custom progress transport.** `getWritable()` streams updates from steps. -Each compensation step must be wired explicitly — and as the saga grows, the nested try/catch blocks multiply. There is no built-in streaming primitive; progress reporting requires a separate Activity that writes to an external store. +Suspended workflows (on `sleep()` or a hook) consume no compute until resumed. -### Workflow SDK version +## Step-by-step first migration -```typescript -import { FatalError, getStepMetadata, getWritable } from 'workflow'; +Pick one Temporal workflow and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path. -type Order = { id: string; customerId: string; total: number }; -type Reservation = { reservationId: string }; -type Charge = { chargeId: string }; -type Shipment = { shipmentId: string }; +### Step 1: Install the Workflow SDK -export async function processOrderSaga(orderId: string) { - 'use workflow'; +Add the runtime. The Next.js integration ships as a subpath (`workflow/next`) of the same package. - const rollbacks: Array<() => Promise> = []; - - try { - const order = await loadOrder(orderId); - await emitProgress({ stage: 'loaded', orderId: order.id }); - - const inventory = await reserveInventory(order); - rollbacks.push(() => releaseInventory(inventory.reservationId)); - await emitProgress({ stage: 'inventory_reserved', orderId: order.id }); - - const payment = await chargePayment(order); - rollbacks.push(() => refundPayment(payment.chargeId)); - await emitProgress({ stage: 'payment_captured', orderId: order.id }); - - const shipment = await createShipment(order); - rollbacks.push(() => cancelShipment(shipment.shipmentId)); - await emitProgress({ stage: 'shipment_created', orderId: order.id }); - - return { - orderId: order.id, - reservationId: inventory.reservationId, - chargeId: payment.chargeId, - shipmentId: shipment.shipmentId, - status: 'completed', - }; - } catch (error) { - while (rollbacks.length > 0) { - await rollbacks.pop()!(); - } - throw error; - } -} +```bash +pnpm add workflow +``` -async function loadOrder(orderId: string): Promise { - 'use step'; - const res = await fetch(`https://example.com/api/orders/${orderId}`); - if (!res.ok) throw new FatalError('Order not found'); - return res.json() as Promise; -} +### Step 2: Collapse Activities into step functions -async function reserveInventory(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/inventory/reservations', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Inventory reservation failed'); - return res.json() as Promise; -} +Delete the `activities/` module and the `proxyActivities` call. Each former Activity becomes a plain async function with `"use step"` on the first line, living next to the orchestrator. -async function releaseInventory(reservationId: string): Promise { - 'use step'; - await fetch( - `https://example.com/api/inventory/reservations/${reservationId}`, - { method: 'DELETE' } - ); +```ts title="workflows/order.ts" +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); } +``` -async function chargePayment(order: Order): Promise { - 'use step'; - const { stepId } = getStepMetadata(); - const res = await fetch('https://example.com/api/payments/charges', { - method: 'POST', - headers: { - 'Idempotency-Key': stepId, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - orderId: order.id, - customerId: order.customerId, - amount: order.total, - }), - }); - if (!res.ok) throw new Error('Payment charge failed'); - return res.json() as Promise; -} +### Step 3: Mark the orchestrator with `"use workflow"` -async function refundPayment(chargeId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/payments/charges/${chargeId}/refund`, { - method: 'POST', - }); -} +Keep the existing control flow: `await`, `try/catch`, `Promise.all`. The directive turns the function into a durable replay target. -async function createShipment(order: Order): Promise { - 'use step'; - const res = await fetch('https://example.com/api/shipments', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orderId: order.id }), - }); - if (!res.ok) throw new Error('Shipment creation failed'); - return res.json() as Promise; +```ts +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + const order = await loadOrder(orderId); + // ... } +``` -async function cancelShipment(shipmentId: string): Promise { - 'use step'; - await fetch(`https://example.com/api/shipments/${shipmentId}`, { - method: 'DELETE', - }); -} +### Step 4: Replace Signals with hooks -async function emitProgress(update: { stage: string; orderId: string }) { - 'use step'; - const writable = getWritable<{ stage: string; orderId: string }>(); - const writer = writable.getWriter(); - try { - await writer.write(update); - } finally { - writer.releaseLock(); - } -} -``` +Swap `defineSignal` + `setHandler` for `createHook()`. Callers `resumeHook(token, payload)` instead of `client.workflow.signal(...)`. -- Temporal compensation logic maps cleanly to a rollback stack in the workflow function. -- Use `getStepMetadata().stepId` as the idempotency key for payment and inventory APIs. -- Stream user-visible progress from steps with `getWritable()` instead of adding a separate progress transport. +### Step 5: Start runs from an API route or server action -## Why teams usually simplify infrastructure in this move +Delete the Worker bootstrap. Launch runs from an API route or server action with `start()`: -Temporal requires you to operate (or pay for) a Temporal Server, deploy and scale a Worker fleet, and manage Task Queue routing. These are powerful abstractions when you need cross-language orchestration or custom search attributes — but for TypeScript-first teams, they are overhead without a corresponding benefit. +```ts title="app/api/orders/route.ts" +import { start } from "workflow/api"; +import { processOrder } from "@/workflows/order"; -With the Workflow SDK: +export async function POST(req: Request) { + const { orderId } = await req.json(); + const run = await start(processOrder, [orderId]); + return Response.json({ runId: run.runId }); +} +``` -- **No workers to operate.** The runtime manages execution scheduling. You deploy your app; workflows run where your app runs. -- **No separate server.** Durable state lives in the managed event log. There is no cluster to provision, monitor, or upgrade. -- **TypeScript all the way down.** Workflow and step functions are regular TypeScript with directive annotations. No code generation, no separate SDK for activities, no `proxyActivities` indirection. -- **Durable streaming built in.** `getWritable()` lets you push progress updates from steps without bolting on a separate WebSocket or SSE transport. -- **Efficient resource usage.** When a workflow is suspended on `sleep()` or a hook, it pauses cleanly instead of keeping a worker process alive. +### Step 6: Retire the Temporal infrastructure -This is not about replacing every Temporal feature. It is about recognizing that most TypeScript teams use a fraction of Temporal's surface and pay for the rest in operational complexity. +Remove the Worker process, `@temporalio/*` dependencies, and the Temporal Server or Cloud connection. Verify the run in the built-in observability UI (`npx workflow web`) before shipping. ## Quick-start checklist -- Move orchestration into a single `"use workflow"` function. -- Convert each Temporal Activity into a `"use step"` function. -- Remove Worker and task-queue application code; start workflows from your app boundary with `start()`. -- Replace Signals with `createHook()` or `createWebhook()` depending on whether the caller is internal or HTTP-based. -- Replace Child Workflows with `"use step"` wrappers around `start()` and `getRun()` when you need independent runs. Return serializable `runId` values to the workflow and collect child results from a step. -- Move retry policy down to step boundaries using default retries, `maxRetries`, `RetryableError`, and `FatalError`. -- Add idempotency keys to external side effects using `getStepMetadata().stepId`. -- Stream user-visible progress from steps with `getWritable()` when you previously used custom progress plumbing. -- Deploy your app and verify workflows run end-to-end with built-in observability. +- Move orchestration into a `"use workflow"` function. +- Convert each Activity into a `"use step"` function. +- Remove Worker and Task Queue code. Start workflows from the app with `start()`. +- Replace Signals with `createHook()` or `createWebhook()` for HTTP callers. +- Wrap `start()` and `getRun()` in `"use step"` functions for child workflows. Return the `Run` object from `start()` so observability can deep-link into child runs. +- Set retry policy per step with `maxRetries`, `RetryableError`, and `FatalError`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Stream status and progress from steps with `getWritable({ namespace: 'status' })`, and have clients read from the stream instead of polling. +- Deploy the app and verify runs end-to-end in the built-in observability UI. diff --git a/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx new file mode 100644 index 0000000000..a2a7b145b2 --- /dev/null +++ b/docs/content/docs/migration-guides/migrating-from-trigger-dev.mdx @@ -0,0 +1,293 @@ +--- +title: Migrating from trigger.dev +description: Move a trigger.dev v3 TypeScript app to the Workflow SDK by replacing task(), schemaTask(), wait.for / wait.forToken, triggerAndWait, and metadata streams with Workflows, Steps, Hooks, and start() / getRun(). +type: guide +summary: Translate a trigger.dev v3 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 +--- + + +Install the Workflow SDK migration skill: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk +``` + + +## Why migrate to the Workflow SDK? + +- Streaming is built in via named streams (`getWritable()` and `getWritable({ namespace })`). Durable status writes replace `metadata.stream()` and `metadata.set()`, and clients read from the end of the stream for current status. +- Orchestration and infrastructure live in a single deployment. There is no separate trigger.dev cloud or self-hosted worker fleet to operate. +- TypeScript-first DX: plain async/await control flow, no `task()` factory, no `schemaTask()` wrapper for payloads you already type with TypeScript. +- Agent-first tooling: the `npx workflow` CLI, `@workflow/ai` for durable AI agents, and a Claude skill for generating workflows. +- Retry policy is per step. Throw `RetryableError` to retry with a delay, or `FatalError` to stop. There is no central task-level retry config. + +## What changes when you leave trigger.dev? + +trigger.dev v3 defines durable work with `task()` or `schemaTask()` from `@trigger.dev/sdk/v3`, deploys tasks to the trigger.dev cloud or a self-hosted instance, and triggers runs via `tasks.trigger()`. A separate worker fleet picks up runs, applies retry policies, and routes `wait.for`, `wait.forToken`, and `metadata.stream` calls through the platform. + +The Workflow SDK replaces that with `"use workflow"` functions that orchestrate `"use step"` functions in plain TypeScript. There is no task registry, separate deploy target, or SDK client. Durable replay, retries, and event history ship with the runtime. + +Migration collapses the task abstraction into plain async functions. Business logic stays the same. + +## Concept mapping + +| trigger.dev | Workflow SDK | Migration note | +| --- | --- | --- | +| `task({ id, run })` | `"use workflow"` function started with `start()` | No factory or id registry. | +| `schemaTask({ schema, run })` | Typed function + `"use workflow"` | Validate inputs at the call site. | +| Inline `run` body | `"use step"` function | Side effects move into named steps. | +| `logger` / `metadata.set` | `console` + `getWritable({ namespace: 'status' })` | Logs flow through the run timeline. Status writes go on a named stream. | +| `wait.for({ seconds })` / `wait.until({ date })` | `sleep()` | Import from `workflow`. | +| `wait.forToken({ timeout })` | `createHook()` + `Promise.race` with `sleep()` | Hooks carry a typed token. | +| `tasks.trigger()` / `triggerAndWait()` | `start()` and `getRun(runId).returnValue` | Wrap both in `"use step"` functions. | +| `batch.triggerAndWait()` | `Promise.all(runIds.map(collectResult))` | Fan out via standard concurrency. | +| `AbortTaskRunError` | `FatalError` | Stops retries immediately. | +| `retry.onThrow` / `retry.fetch` | `RetryableError`, `FatalError`, `maxRetries` | Retry lives on the step. Exponential backoff is a step `maxRetries` config, not a helper. | +| `metadata.stream()` / Realtime | `getWritable()` / `getWritable({ namespace })` | Named streams are the canonical read channel. Clients read from the end of the stream for current status. Do not poll `getRun()` or persist status to a database for client reads. | +| Self-hosted worker + dashboard | Managed execution + built-in UI | No worker fleet to operate. | + +## Translate your first workflow + +Start with the shell. trigger.dev wraps the handler in `task()`; the Workflow SDK marks the function with a directive. + +```typescript title="trigger/order.ts (trigger.dev)" +import { task } from '@trigger.dev/sdk/v3'; + +export const processOrder = task({ + id: 'process-order', + run: async (payload: { orderId: string }) => { + return { orderId: payload.orderId, status: 'completed' }; + }, +}); +``` + +```typescript title="trigger/order.ts (Workflow SDK)" +export async function processOrder(orderId: string) { + 'use workflow'; // [!code highlight] + return { orderId, status: 'completed' }; +} +``` + +**What changed:** the `task()` factory and its `id` field disappear. The function is a plain export tagged with `"use workflow"`. + +### Add a step + +The body of `run` becomes one or more `"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 with a plain `await`. Each additional side effect (`reserveInventory`, `chargePayment`) follows the same shape. + +### Start the run + +trigger.dev dispatches with `tasks.trigger('process-order', { orderId })`. The Workflow SDK calls `start()` directly: + +```typescript title="app/api/orders/route.ts" +import { start } from 'workflow/api'; +import { processOrder } from '@/workflow/workflows/order'; + +export async function POST(request: Request) { + const { orderId } = (await request.json()) as { orderId: string }; + const run = await start(processOrder, [orderId]); // [!code highlight] + return Response.json({ runId: run.runId }); +} +``` + +No id lookup, no API key, no separate worker. `start()` returns a handle immediately. + +## Wait for an external signal + +`wait.forToken()` becomes `createHook()` plus `await`. + +```typescript title="workflow/workflows/refund.ts (trigger.dev, abbreviated)" +// import { wait } from '@trigger.dev/sdk/v3'; +const token = await wait.createToken({ timeout: '7d' }); +const approval = await wait.forToken<{ approved: boolean }>(token.id).unwrap(); +// External system resumes with: await wait.completeToken(token.id, { approved: true }); +``` + +```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:** the platform-issued opaque token becomes an app-owned string. The caller that resumes the run supplies that same string, so there is no token lookup. + +### Resume from an API route + +trigger.dev completes a token with `wait.completeToken(tokenId, { 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 + +trigger.dev's `timeout: '7d'` option maps to a `Promise.race()` with `sleep()`: + +```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' }; +``` + + +A hook is an inbound write channel. The caller that knows the token resumes the run with a typed payload. To expose in-flight state to a dashboard, write updates from a step with `getWritable()` (or `getWritable({ namespace: 'status' })`), and have the client read from the end of that named stream. + + +## Spawn a child workflow + +`triggerAndWait()` splits into two steps: spawn and collect. `start()` and `getRun()` are runtime APIs, so wrap them in `"use step"` functions. Return the full `Run` object from `spawnChild` so observability tooling can deep-link to the child run. + +```typescript title="workflow/workflows/parent.ts" +import { start } from 'workflow/api'; + +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: + +```typescript title="workflow/workflows/parent.ts" +import { getRun } from 'workflow/api'; + +async function collectResult(runId: string) { + 'use step'; + const run = getRun(runId); + return (await run.returnValue) as { item: string; result: string }; // [!code highlight] +} + +export async function parentWorkflow(item: string) { + 'use workflow'; + const child = await spawnChild(item); + return await collectResult(child.runId); +} +``` + +To fan out, call `spawnChild` inside a loop, then `Promise.all` the `collectResult` calls. That replaces `batch.triggerAndWait()`. + +## What you stop operating + +Dropping the trigger.dev SDK removes several moving parts: + +- **No task registry or `id` strings.** Workflow files carry directive annotations and export plain functions. +- **No `@trigger.dev/sdk/v3` client or API key.** `start()` launches runs directly from API routes or server actions. +- **No worker fleet or self-hosted instance.** The runtime schedules execution inside the app's deploy target. +- **No separate Realtime channel.** `getWritable()` streams updates from steps over the run's durable stream. +- **No dashboard account.** The built-in observability UI (`npx workflow web`) reads the same event log the runtime writes. + +Workflows suspended on `sleep()` or a hook consume no compute until resumed. + +## Step-by-step first migration + +Pick one trigger.dev task 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. The Next.js integration ships as the `workflow/next` subpath of the same package. + +```bash +pnpm add workflow +``` + +### Step 2: Convert `task()` to a `"use workflow"` export + +Drop the factory call and the `id`. Move the handler body up and replace the payload object with typed function arguments. + +```ts title="workflows/order.ts" +// Before (trigger.dev) +// export const processOrder = task({ +// id: "process-order", +// run: async ({ orderId }: { orderId: string }) => { ... }, +// }); + +// After (Workflow SDK) +export async function processOrder(orderId: string) { + "use workflow"; // [!code highlight] + // ... +} +``` + +### Step 3: Convert the task body into `"use step"` named functions + +Each side effect becomes a named function with `"use step"` on the first line. The workflow calls them with a plain `await`. `schemaTask()` validation moves to the call site: validate with zod before calling `start()`. + +```ts +async function loadOrder(id: string) { + "use step"; // [!code highlight] + return fetch(`/api/orders/${id}`).then((r) => r.json()); +} +``` + +### Step 4: Replace `wait.*` with hooks and `sleep` + +- `wait.for({ seconds })` / `wait.until({ date })` → `sleep('5m')` or `sleep(date)` from `workflow`. +- `wait.forToken(token)` → `createHook({ token })` + `await`. Complete it with `resumeHook(token, payload)` from an API route. +- `wait.forToken({ timeout })` → `Promise.race([hook, sleep(timeout)])`. +- `triggerAndWait(payload)` → wrap `start(child, [payload])` in a `"use step"` function and return the `Run` object, then read the result with a second step that calls `getRun(runId).returnValue`. + +### Step 5: Start runs from the app + +Delete the trigger.dev client setup. Launch runs directly from an API route or server action: + +```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 trigger.dev infrastructure + +Remove the `@trigger.dev/sdk` dependency, the `trigger.config.ts` file, the `trigger/` directory, and any self-hosted worker deployment. Delete dashboard API keys from the environment. Verify the run in `npx workflow web` before shipping. + +## Quick-start checklist + +- Replace `task({ id, run })` with a `"use workflow"` function; launch it with `start()`. +- Convert each task body into named `"use step"` functions. +- Swap `wait.for` / `wait.until` for `sleep()` from `workflow`. +- Swap `wait.forToken` for `createHook()` (internal) or `createWebhook()` (HTTP). +- Model `wait.forToken` timeouts as `Promise.race()` between the hook and `sleep()`. +- Replace `triggerAndWait()` with `"use step"` wrappers around `start()` and `getRun()`. +- Replace `batch.triggerAndWait()` with `Promise.all` over the collected child `Run` handles. +- Move `schemaTask` validation to the call site; pass typed arguments into the workflow. +- Replace `AbortTaskRunError` with `FatalError`; model retries per step with `RetryableError` and `maxRetries`. +- Use `getStepMetadata().stepId` as the idempotency key for external side effects. +- Replace `metadata.stream()` and Realtime with `getWritable()`. +- Remove the `@trigger.dev/sdk` dependency, `trigger.config.ts`, and any self-hosted worker. +- Deploy and verify runs end-to-end with the built-in observability UI. diff --git a/skills/migrating-to-workflow-sdk/SKILL.md b/skills/migrating-to-workflow-sdk/SKILL.md index 3f1f319ba2..bd220b5694 100644 --- a/skills/migrating-to-workflow-sdk/SKILL.md +++ b/skills/migrating-to-workflow-sdk/SKILL.md @@ -1,9 +1,9 @@ --- name: migrating-to-workflow-sdk -description: Migrates Temporal, Inngest, and AWS Step Functions workflows to the Workflow SDK. Use when porting Activities, Workers, Signals, step.run(), step.waitForEvent(), ASL JSON state machines, Task/Choice/Wait/Parallel states, task tokens, or child workflows. +description: Migrates Temporal, Inngest, Trigger.dev, and AWS Step Functions workflows to the Workflow SDK. Use when porting Activities, Workers, Signals, step.run(), step.waitForEvent(), Trigger.dev tasks / wait.forToken / triggerAndWait, ASL JSON state machines, Task/Choice/Wait/Parallel states, task tokens, or child workflows. metadata: author: Vercel Inc. - version: '0.1.9' + version: '0.1.10' --- # Migrating to the Workflow SDK @@ -15,6 +15,7 @@ Use this skill when converting an existing orchestration system to the Workflow 1. Identify the source system: - Temporal - Inngest + - Trigger.dev - AWS Step Functions 2. Identify the target runtime: - Managed hosting -> keep examples focused on `start()`, `getRun()`, hooks/webhooks, and route handlers. @@ -72,6 +73,7 @@ Before drafting `## Migrated Code`, write the selected route keys in `## Migrati - Temporal -> `references/temporal.md` - Inngest -> `references/inngest.md` +- Trigger.dev -> `references/trigger-dev.md` - AWS Step Functions -> `references/aws-step-functions.md` ## Shared references diff --git a/skills/migrating-to-workflow-sdk/references/trigger-dev.md b/skills/migrating-to-workflow-sdk/references/trigger-dev.md new file mode 100644 index 0000000000..dba46f1701 --- /dev/null +++ b/skills/migrating-to-workflow-sdk/references/trigger-dev.md @@ -0,0 +1,53 @@ +# Trigger.dev -> Workflow SDK + +## Map these constructs + +| Trigger.dev | Workflow SDK | +| --- | --- | +| `task({ id, run })` | `"use workflow"` or `"use step"` | +| `schemaTask({ schema, run })` | `"use workflow"` with plain typed args | +| `wait.for({ seconds })` | `sleep()` | +| `wait.until({ date })` | `sleep()` until target date | +| `wait.forToken({ timeout })` | `createHook()` or `createWebhook()` | +| `task.triggerAndWait()` | step-wrapped `start()` + `getRun().returnValue` | +| `task.trigger()` | `start()` from `workflow/api` | +| `batch.triggerAndWait()` | parallel `start()` + `Promise.all(runIds.map(id => getRun(id).returnValue))` | +| `tasks.trigger()` from API route / server action | `start()` from `workflow/api` | +| `AbortTaskRunError` | `FatalError` | +| `retry.onThrow` / `retry.fetch` | step `RetryableError` + `maxRetries` | +| `retry` options on `task()` | `maxRetries` on the step | +| `queue` / `machine` config on `task()` | remove from app code | +| `logger.info` / `logger.warn` | standard logging | +| `metadata.set()` | `executionContext` or step return values | +| `metadata.stream()` | `getWritable()` | + +## Remove + +- `@trigger.dev/sdk` task registration and `client.defineJob` / `task()` wiring +- `trigger.config.ts` project config, queue config, and machine config +- `schemaTask()` zod wrapper layer — move validation to the app boundary +- `tasks.trigger()` / `runs.retrieve()` imports inside task bodies in favor of `start()` / `getRun()` +- `wait.forToken()` token-issuance plumbing after converting to hooks/webhooks +- `AbortTaskRunError` imports after converting to `FatalError` +- `retry.onThrow` / `retry.fetch` wrappers after converting to step-level `RetryableError` + `maxRetries` +- `metadata.*` and `logger.*` runtime helpers after converting to `getWritable()` or standard logging + +## Add + +- `Promise.race()` with `sleep()` when the source used `wait.forToken({ timeout })` or `wait.for()` as a deadline +- Resume surface for `wait.forToken()` migrations: + - Use `createHook()` + `resumeHook()` when the app resumes the workflow from server-side code with a deterministic business token. + - Use `createWebhook()` when the external system needs a generated callback URL or the migrated flow should receive a raw `Request`, and the default `202 Accepted` response is fine. + - Use `createWebhook({ respondWith: 'manual' })` only when the prompt explicitly requires a custom response body, status, or headers. + - Choose exactly one surface. Do not pair `createWebhook()` with `resumeHook()`. + - See `references/shared-patterns.md` -> `## Deterministic server-side resume` + - See `references/shared-patterns.md` -> `## Generated callback URL (default response)` + - See `references/shared-patterns.md` -> `## Generated callback URL (manual response)` +- Durable progress writes with `getWritable()` (replaces `metadata.stream()`) +- Idempotency keys on external writes via `getStepMetadata().stepId` +- Step-level `RetryableError` + `maxRetries` (replaces `retry.onThrow` and `retry.fetch`; exponential backoff moves to `maxRetries` config) +- `FatalError` at step boundaries (replaces `AbortTaskRunError`) +- Step-wrapped `start()` / `getRun()` for child runs (replaces `task.triggerAndWait()` and `batch.triggerAndWait()`) +- Parallel fan-out via `Promise.all()` over step-wrapped `start()` calls (replaces `batch.triggerAndWait()`) +- App-boundary `start()` from `workflow/api` in API routes / server actions (replaces `tasks.trigger()`) +- Rollback stack for compensation-heavy flows (replaces per-task `onFailure` hooks)