npx skills add ...
npx skills add vercel-labs/vercel-plugin --skill workflow
Vercel Workflow SDK expert guidance. Use when building durable workflows, long-running tasks, API routes or agents that need pause/resume, retries, step-based execution, or crash-safe orchestration with Vercel Workflow.
This repo is now called vercel/vercel-plugin. Both names install the same content, but the install count here only covers this one.
npx skills add vercel-labs/vercel-plugin --skill workflow
workflow DocumentationYour knowledge of workflow is outdated.
The workflow documentation outlined below matches the installed version of the Workflow SDK.
Follow these instructions before starting on any workflow-related tasks:
Search the bundled documentation in node_modules/workflow/docs/:
glob "node_modules/workflow/docs/**/*.mdx"grep "your query" node_modules/workflow/docs/Documentation structure in node_modules/workflow/docs/:
getting-started/ - Framework setup (next.mdx, express.mdx, hono.mdx, etc.)foundations/ - Core concepts (workflows-and-steps.mdx, hooks.mdx, streaming.mdx, etc.)api-reference/workflow/ - API docs (sleep.mdx, create-hook.mdx, fatal-error.mdx, etc.)api-reference/workflow-api/ - Client API (start.mdx, get-run.mdx, resume-hook.mdx, etc.)api-reference/workflow-runtime/ - Runtime API (get-world.mdx) and world/ World SDK (storage.mdx, streams.mdx, queue.mdx)api-reference/workflow-observability/ - Hydration and name parsing utilities (hydrate-resource-io.mdx, parse-workflow-name.mdx, etc.)ai/ - AI SDK integration docserrors/ - Error code documentationRelated packages also include bundled docs:
@workflow/ai: node_modules/@workflow/ai/docs/ - DurableAgent and AI integration@workflow/core: node_modules/@workflow/core/docs/ - Core runtime (foundations, how-it-works)@workflow/next: node_modules/@workflow/next/docs/ - Next.js integrationWhen in doubt, update to the latest version of the Workflow SDK.
Directives:
Essential imports:
"use workflow" functions run in a sandboxed VM. "use step" functions have full Node.js access. Put your logic in steps and use the workflow function purely for orchestration.
Benefits: Steps have automatic retry, results are persisted for replay, and no sandbox restrictions.
When you need logic directly in a workflow function (not in a step), these restrictions apply:
| Limitation | Workaround |
|---|---|
No fetch() | import { fetch } from "workflow" then globalThis.fetch = fetch |
No setTimeout/setInterval | Use sleep("5s") from "workflow" |
| No Node.js modules (fs, crypto, etc.) | Move to a step function |
Example - Using fetch in workflow context:
Note: DurableAgent from @workflow/ai handles the fetch assignment automatically.
Use DurableAgent to build AI agents that maintain state and survive interruptions. It handles the workflow sandbox automatically (no manual globalThis.fetch needed).
Key points:
getWritable<UIMessageChunk>() streams output to the workflow run's default streamexecute functions that need Node.js/npm access should use "use step"execute functions that use workflow primitives (sleep(), createHook()) should NOT use "use step" — they run at the workflow levelmaxSteps limits the number of LLM calls (default is unlimited)result.messages plus new user messages to subsequent agent.stream() callsFor more details on DurableAgent, check the AI docs in node_modules/@workflow/ai/docs/.
Use start() to launch workflows from API routes. start() cannot be called directly in workflow context — wrap it in a step function.
Starting child workflows from inside a workflow — must use a step:
start() returns immediately — it doesn't wait for the workflow to complete. Use run.returnValue to await completion.
Hooks let workflows wait for external data. Use createHook() inside a workflow and resumeHook() from API routes. Deterministic tokens are for createHook() + resumeHook() (server-side) only. createWebhook() always generates random tokens — do not pass a token option to createWebhook().
Hooks implement AsyncIterable — use for await...of to receive multiple events:
Each resumeHook(token, payload) call delivers the next value to the loop.
Use FatalError for permanent failures (no retry), RetryableError for transient failures:
All data passed to/from workflows and steps must be serializable.
Supported built-in types: string, number, boolean, null, undefined, bigint, plain objects, arrays, Date, RegExp, URL, URLSearchParams, Map, Set, Headers, ArrayBuffer, typed arrays, Request, Response, ReadableStream, WritableStream.
Not supported: Functions, Symbols, WeakMap/WeakSet. Pass data, not callbacks.
Class instances can be serialized across workflow/step boundaries by implementing the @workflow/serde protocol. This is essential when a class has instance methods with "use step" or when you want to pass class instances between steps.
Install: @workflow/serde must be a dependency of the package containing the class.
Pattern: Add two static methods inside the class body using computed property syntax:
Critical rules:
static [WORKFLOW_SERIALIZE](...)). The SWC plugin detects them by scanning the class. Do NOT assign them externally (e.g., (MyClass as any)[WORKFLOW_SERIALIZE] = ...) -- the compiler will not detect this."use step" to Node.js-dependent instance methods. The SWC plugin strips "use step" method bodies from the workflow bundle. This is how you keep Node.js imports (fs, crypto, child_process, etc.) out of the workflow sandbox. The class shell with its serde methods remains in the workflow bundle; only the step method bodies are removed.classId and adds the class to the global registry). Manual calls to registerSerializationClass() are unnecessary and error-prone."use step", not /* @vite-ignore */ import(...).When serde works well: Pure data classes, domain models, configuration objects, and classes where Node.js-dependent methods can be marked with "use step".
When to avoid serde: If a class is fundamentally inseparable from Node.js APIs (every method needs fs, net, etc.) and cannot meaningfully exist as a shell in the workflow sandbox, keep it entirely in step functions and pass plain data objects across boundaries instead.
Use these tools to verify classes are correctly set up:
workflow transform <file> --check-serde -- Shows the SWC transform output for a file and checks if serde classes are compliant (no Node.js imports remaining in the workflow bundle).workflow validate -- Scans all workflow files and reports serde compliance issues. Use --json for machine-readable output.workbench/swc-playground shows a Serde Analysis panel when serde patterns are detected.Use getWritable() to stream data from workflows. getWritable() can be called in both workflow and step contexts, but you cannot interact with the stream (call getWriter(), write(), close()) directly in a workflow function. The stream must be passed to step functions for actual I/O, or steps can call getWritable() themselves.
Get the stream in a workflow, pass it to a step:
Call getWritable() directly inside a step (no need to pass it):
Use getWritable({ namespace: 'name' }) to create multiple independent streams for different types of data. This is useful for separating logs from primary output, different log levels, agent outputs, metrics, or any distinct data channels. Long-running workflows benefit from namespaced streams because you can replay only the important events (e.g., final results) while keeping verbose logs in a separate stream.
Example: Log levels and agent output separation:
Consuming namespaced streams:
Pro tip: For very long-running sessions (50+ minutes), namespaced streams help manage replay performance. Put verbose/debug output in separate namespaces so you can replay just the important events quickly.
Use --url to print the dashboard deep link and exit — no browser opens and
no local server starts. This is the right tool when you need to hand a user a
clickable link (PR comment, Slack message, debugging summary) rather than open a
UI. (--web opens the dashboard; --url only prints the link.)
URL formats produced:
https://vercel.com/<team-slug>/<project-slug>/workflows/runs/<run_id>?environment=<production|preview>
(--env selects the environment; defaults to production. Resolving the team
slug requires being logged in via vercel login with the project linked.)http://localhost:<port>?resource=run&id=<run_id> (port defaults
to 3456; the link works while the npx workflow web server is running).stdout contains only the URL (or the JSON object) — all other output goes to
stderr — so you can capture it directly, e.g. URL=$(npx workflow web <run_id> --backend vercel --url).
Debugging tips:
--json (-j) on any command for machine-readable output--web to open the Vercel Observability dashboard in your browser, or --url to just print the deep link--help on any command for full usage detailsWorkflow SDK provides a Vitest plugin for testing workflows in-process — no running server required.
Unit testing steps: Steps are just functions; without the compiler, "use step" is a no-op. Test them directly:
Integration testing: Use @workflow/vitest for workflows using sleep(), hooks, webhooks, or retries:
Testing webhooks: Use resumeWebhook() with a Request object — no HTTP server needed:
Key APIs:
start() — trigger a workflowrun.returnValue — await workflow completionwaitForHook(run, { token? }) / waitForSleep(run) — wait for workflow to reach a pause pointresumeHook(token, data) / resumeWebhook(token, request) — resume paused workflowsgetRun(runId).wakeUp({ correlationIds }) — skip sleep() callsBest practices:
workflow() plugin) in separate configstestTimeout — workflows may run longer than typical unit testsvi.mock() does not work in integration tests — step dependencies are bundled by esbuildUse await getWorld() to build observability dashboards, admin panels, and inspect workflow state. getWorld() is asynchronous and returns Promise<World> (dynamic import / env-based setup).
Key imports:
Key docs (grep node_modules/workflow/docs/ for full details):
api-reference/workflow-runtime/world/storage.mdx — events, runs, steps, hooks (events are source of truth; others are materialized views)api-reference/workflow-observability/ — hydration and name parsing⚠️ Pagination is nested: { pagination: { cursor } } — NOT { cursor } directly.
resolveData ParameterControls whether input/output data is included in the response. Accepts 'all' (default) or 'none'.
IMPORTANT: Even with 'all', data is still devalue-serialized. You MUST call hydrateResourceIO() to get usable JS values.
'none' for status polling, progress dashboards, run listings'all' (or omit) when you need to inspect actual step I/O data — then always hydrateCommon mistake: Checking
step.input !== undefinedafterresolveData: 'all'and assuming the data is ready to use. The data exists but is serialized — always hydrate first.
Step I/O is serialized via devalue with a 4-byte format prefix (devl). Without hydration, input/output are Uint8Array-like objects with numeric keys:
{"0":100,"1":101,"2":118,"3":108,...} — these are NOT usable values.
Always hydrate before using I/O data:
hydrateResourceIO works on both Step and WorkflowRun objects. For encrypted workflows, use getEncryptionKeyForRun() + hydrateResourceIOWithKey().
parseWorkflowName(), parseStepName(), and parseClassName() return { shortName: string, moduleSpecifier: string } | null. Always use optional chaining:
Events are the append-only source of truth. Runs/Steps/Hooks are materialized views.
| Category | Types |
|---|---|
| Run | run_created, run_started, run_completed, run_failed, run_cancelled |
| Step | step_created, step_started, step_completed, step_failed, step_retrying |
| Hook | hook_created, hook_received, hook_disposed, hook_conflict |
| Wait | wait_created, wait_completed |
Three error strategies for different failure modes:
| Error Type | Use When | Behavior |
|---|---|---|
FatalError | Permanent failure (bad input, auth denied) | Terminates workflow immediately, no retry |
RetryableError | Transient failure (rate limit, timeout) | Retries with optional retryAfter delay |
Promise.allSettled | Parallel steps with mixed criticality | Continues even if some steps fail |