


















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.
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:
npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdkawait, branches are if/switch, and parallelism is Promise.all.getWritable() and named streams. No DynamoDB or SNS glue to surface status to clients.RetryableError, and FatalError replace per-state Retry/Catch blocks.npx workflow CLI and npx workflow web observability UI ship out of the box.@workflow/ai for AI-SDK integration and the Claude migration skill — are available as separate installs.This guide assumes Standard workflows. Express workflows have different semantics (at-least-once, 5-minute max duration, no execution history) and may need a different target — consider keeping them on Step Functions, moving them to a queue consumer, or ensuring your steps are idempotent before replaying the pattern here.
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.
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 replaces declarative configuration with idiomatic TypeScript and collapses the orchestrator and compute split. Business logic stays the same.
| AWS Step Functions | Workflow SDK | Migration note |
|---|---|---|
| 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 | Inline sequential → for loop; bounded parallel (MaxConcurrency: N) → batched Promise.all or a concurrency limiter like p-limit; Distributed Map / large fan-out → step-wrapped start() per item, then step-wrapped getRun() to collect. | Match the concurrency mode of the original Map. |
| Retry / Catch | Step retries, RetryableError, FatalError | Retry logic moves to step boundaries. |
Catch to a compensation state | try/catch in the workflow function, calling compensation steps in reverse order (push/pop a rollback stack) | See /docs/foundations/errors-and-retries for the SAGA pattern. |
.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.
Start with a single Task state. In ASL, even "call one Lambda" requires a state machine shell:
"LoadOrder": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "loadOrder", "Payload.$": "$" },
"End": true
}Examples use JSONPath mode. If your state machine sets QueryLanguage: 'JSONata', the shape of Arguments/Output fields differs but the TypeScript translation is identical.
export async function processOrder(orderId: string) {
'use workflow';
return await loadOrder(orderId);
}
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: the ASL state machine and its Lambda collapse into two directive-tagged functions in one file.
In ASL, a second Task means a new state and a "Next" transition. In the Workflow SDK, it's another await:
export async function processOrder(orderId: string) {
'use workflow';
const order = await loadOrder(orderId);
await reserveInventory(order.id);
return { orderId: order.id, status: 'reserved' };
}await replaces "Next". Each new step is a new function with "use step"; no additional deployment. The second version also reshapes the return value; the workflow return type can be anything serializable.
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:
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]);
return Response.json({ runId: run.runId });
}A Wait state becomes sleep():
{ "Type": "Wait", "Seconds": 60, "Next": "Next" }The minimal ASL for a callback is a Task with .waitForTaskToken:
"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
}import { createHook } from 'workflow';
export async function refundWorkflow(refundId: string) {
'use workflow';
using approval = createHook<{ approved: boolean }>({
token: `refund:${refundId}:approval`,
});
return await approval;
}What changed: no SQS queue, no task token, no callback Lambda. The hook suspends the workflow durably until it is resumed.
Step Functions resumes by calling SendTaskSuccess with the task token. The Workflow SDK resumes by calling resumeHook with the hook's token:
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 });
return Response.json({ ok: true });
}In ASL, branching after the wait requires a Choice state. In TypeScript, it's just if/else:
"CheckApproval": {
"Type": "Choice",
"Choices": [
{ "Variable": "$.approved", "BooleanEquals": true, "Next": "Approved" }
],
"Default": "Rejected"
}const { approved } = await approval;
if (approved) return { refundId, status: 'approved' };
return { refundId, status: 'rejected' };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.
import { start } from 'workflow/api';
async function spawnChild(item: string) {
'use step';
return await start(childWorkflow, [item]);
}
export async function parentWorkflow(item: string) {
'use workflow';
const run = await spawnChild(item);
return { childRunId: run.runId };
}Add a second step that wraps getRun() and awaits returnValue:
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 };
}Then in the workflow: const result = await collectResult(run.runId);. The child workflow itself (childWorkflow) is defined elsewhere with "use workflow".
Moving off Step Functions removes these surfaces from the application:
Workflow and step functions live in the same deployment as the application. State transitions are ordinary control flow (await, if, Promise.all, for). Progress streaming, retries, and observability are built in.
Steps that previously invoked AWS services via optimized integrations (EventBridge, DynamoDB, Bedrock, ECS.RunTask.sync, etc.) become ordinary SDK calls inside 'use step' functions. Credentials and retries move into the step, and .sync-style waits for long-running jobs become explicit polling loops or hook-based callbacks.
Pick one state machine and migrate it end-to-end before touching the rest. The steps below describe the smallest viable path.
Add the workflow runtime package.
"use workflow" functionTransitions become await calls. Control flow (Choice, Wait, Parallel, Map) becomes if/switch, sleep, Promise.all, and loops.
export async function processOrder(orderId: string) {
"use workflow";
const order = await loadOrder(orderId);
if (order.total > 1000) await reviewManually(order);
await chargePayment(order);
}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.
async function loadOrder(id: string) {
"use step";
return fetch(`/api/orders/${id}`).then((r) => r.json());
}.waitForTaskToken with a hookSwap the task-token callback Lambda for createHook(). Callers resumeHook(token, payload) instead of SendTaskSuccess.
Move Retry/Catch off per-state configuration and onto step boundaries. Set maxRetries as a function property; throw RetryableError or FatalError to control retry behavior:
async function chargePayment(orderId: string) {
"use step";
// ...
}
chargePayment.maxRetries = 5;See /docs/foundations/errors-and-retries for the full retry and SAGA compensation patterns.
Delete the StartExecution call and IAM wiring. Launch runs directly from a route handler:
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 });
}Delete the ASL JSON, per-task Lambda deployments, IAM roles, and callback queues. Remove CloudWatch and X-Ray wiring that was specific to orchestrator state transitions — keep alarms, log retention, and traces for resources you still depend on. Verify the run in npx workflow web before shipping.
start() per item, then Promise.all with p-limit to bound concurrency.arn:aws:states:::dynamodb:*, eventbridge:*, bedrock:*, ecs:runTask.sync, etc.). These become regular SDK calls inside 'use step' functions — credentials, retries, and polling move into the step.JSONata QueryLanguage mode. Valid at the source; the TS translation is identical regardless of mode."use workflow" function. Transitions become await calls."use step" function in the same file.if/else/switch.sleep() from workflow.Promise.all().for loop; bounded parallel (MaxConcurrency: N) → batched Promise.all or a concurrency limiter like p-limit; Distributed Map / large fan-out → step-wrapped start() per item, then step-wrapped getRun() to collect.StartExecution child machines with "use step" wrappers around start() and getRun()..waitForTaskToken with createHook() (internal callers) or createWebhook() (HTTP callers).maxRetries, RetryableError, and FatalError.getStepMetadata().stepId as the idempotency key for external side effects.getWritable() instead of polling DynamoDB or SNS.Verified against workflow@5.0.0-beta.1 and the AWS Step Functions Amazon States Language spec on 2026-04-16.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。