# Workflow SDK documentation > Workflow SDK is a durable functions framework for JavaScript and TypeScript that makes long-running application logic resilient across stateless compute. ## When to use Workflow SDK - Category: Developer tools - Audience: JavaScript developers, TypeScript developers, AI agent builders Common use cases: - Build durable workflows and steps - Run long-lived AI agents with persisted state - Deploy workflow-backed applications across supported frameworks ## Documentation --- title: Deploying description: Deploy workflows locally, on Vercel, or anywhere using pluggable World adapters. docs_index: /llms.txt lastUpdated: 2026-10-21 type: overview summary: Learn how to deploy workflows to different environments using World adapters. related: - /worlds/local - /worlds/postgres - /worlds/vercel - /worlds/building-a-world --- > For an index of all documentation, see [/llms.txt](/llms.txt). The same workflow code can run locally during development, on Vercel with zero configuration, or on any infrastructure using **Worlds**, which are pluggable adapters that handle storage, queuing, and communication. ## Local development During local development, workflows use the **Local World** without configuration. The Local World stores workflow data in a `.workflow-data/` directory and processes steps synchronously for development and testing. ```bash # Run your dev server to use workflows locally npm run dev ``` You can inspect local workflow data using the CLI: ```bash npx workflow inspect runs ``` > Learn more about the [Local World](/worlds/local) configuration and internals. ## Deploying to Vercel Deploy workflows to production on Vercel without configuration. Workflows deployed to Vercel use the **Vercel World**. The Vercel World provides: - **Durable storage**: Workflow state persists across function invocations - **Managed queuing**: The queue processes steps reliably with automatic retries - **Automatic scaling**: Workflows scale with your application - **Built-in observability**: View workflow runs in the Vercel dashboard - **Multi-region**: The region that creates each run also hosts its workflow data, queue, and streams, keeping them close to your users Deploy your application: ```bash vercel deploy ``` > **Enable access to System Environment Variables before deploying.** Workflow > recognizes a Vercel deployment by `VERCEL_DEPLOYMENT_ID`, which Vercel exposes > to your build and your functions only when the **Enable access to System > Environment Variables** checkbox is selected under **Settings**, then > **Environment Variables**, in your project. With it cleared, the deployment > falls back to the Local World and every run fails on the read-only > filesystem. See > [System environment variables](/worlds/vercel#system-environment-variables). > Learn more about the [Vercel World](/worlds/vercel) and its capabilities, including [multi-region](/worlds/vercel#multi-region). ## Self-hosting & other providers For self-hosting or deploying to other cloud providers, you can use community-maintained Worlds or build your own. #### [Explore Worlds](/worlds) Browse official and community World implementations with compatibility status and performance benchmarks. #### [Build Your Own](/worlds/building-a-world) Learn how to implement a custom World for your infrastructure. ### Using a third-party World To use a different World implementation, set the `WORKFLOW_TARGET_WORLD` environment variable: ```bash export WORKFLOW_TARGET_WORLD=@workflow/world-postgres # Plus any world-specific configuration export DATABASE_URL=postgres://... ``` Each World may have its own configuration requirements. Refer to that World's documentation for details. ## Observability The [Observability tools](/docs/observability) work with any World backend. By default, they connect to your local environment, but you can configure them to inspect remote deployments: ```bash # Inspect local workflows npx workflow inspect runs # Inspect remote workflows npx workflow inspect runs --backend @workflow/world-postgres ``` Learn more about [Observability](/docs/observability) tools. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: Performance description: "How we measure the Workflow SDK runtime's performance, the metrics we track, and the latest results." docs_index: /llms.txt lastUpdated: 2026-10-21 type: reference summary: The benchmarks we run on the Workflow SDK runtime, how each one is measured, and the latest results. related: - /docs/whats-new - /docs/how-it-works/event-sourcing - /docs/foundations/hooks - /docs/foundations/streaming --- > For an index of all documentation, see [/llms.txt](/llms.txt). We benchmark the Workflow SDK runtime on Vercel at each point where a workflow waits on it: starting a run, moving from one step to the next, fanning out, resuming from a hook, and delivering a stream. This page explains what each benchmark measures and how, and then shows the [latest results](#latest-results), which compare a new release with an earlier one. ## What we measure Each benchmark starts a clock at one line of code and stops it at another. The work inside a step is not counted, so the numbers measure the runtime itself. Choose a metric below, then play or scrub through a run to see when its clock runs. ### Time to first step (TTFS) How long it takes from your app calling [`start()`](/docs/api-reference/workflow-api/start) until the first line of the workflow's first step runs. This covers creating and queuing the run, running the workflow code up to its first step, and starting that step. It is measured on a five-step workflow. See [Steps and long runs](#steps-and-long-runs) for the latest results. ### Step-to-step overhead (STSO) How long it takes from one step finishing until the next step starts: recording the step's result, resuming the workflow, and starting the next step. It is measured on the same five-step workflow, and at steps 1 to 20, 101 to 120, and 1,001 to 1,020 of a 1,020-step workflow, which shows whether the overhead grows as the run's [event log](/docs/how-it-works/event-sourcing) grows. See [Steps and long runs](#steps-and-long-runs) for the latest results. ### Fan-out (TTFS, TTLS, join) A workflow starts 64 one-step branches at once with `Promise.all`. The runtime queues the branches in batches, so they start close together and in no fixed order. The first branch (TTFS) and the slowest branch to start, or time to last step (TTLS), are timed from `start()` until that branch's first line runs. The join is timed from the slowest branch finishing until the code after `Promise.all` runs. See [Fan-out](#fan-out) for the latest results. ### Time to resume (TTR) A workflow creates a [hook](/docs/foundations/hooks) and waits on it for 1 s, the way an agent waits for a person's approval, and then your app calls `resumeHook()`. Time to resume runs from that call until the first line of the workflow's next step runs. Each run resumes five times. See [Resuming a workflow](#resuming-a-workflow) for the latest results. ### Chunk trip time (CTT) One step replays a recorded AI agent's token stream, 2,593 chunks over 52.4 s, in real time to a [namespaced stream](/docs/foundations/streaming#namespaced-streams) with [`getWritable()`](/docs/api-reference/workflow/get-writable), and a second step in the same run reads it with `getRun(runId).getReadable()`. Chunk trip time runs from the writer writing a chunk until the reader receives it. The same benchmark records how long the whole response takes to reach the reader and how many runs had a chunk delayed by over 1 s. See [Streams](#streams) for the latest results. ## Methodology - **Platform**: Each version runs the same benchmark app on Vercel production in `iad1`, against the managed [Vercel World](/worlds/vercel), on its own deployment. The versions are measured at the same time. Before measuring, each deployment runs two short workflows and one fan-out that are not counted. - **Workload**: Every step takes 1 KB of JSON in and out and does 100 ms of simulated work, which the overhead measurements exclude. - **Runs**: Each version runs every benchmark several times, and each result pools the samples from all of its runs. The run counts are listed with the latest results. - **Percentiles**: Each percentile pools every sample across a version's runs and uses the nearest-rank method. Half of the samples are at or below the p50 (the median), three quarters at or below the p75, and all but the slowest 1% at or below the p99. With 100 or fewer samples, a p99 is the slowest sample. ### Limits - The benchmarks run in one region, against one World. Other regions, Worlds, and workloads will see different numbers. - The versions share the platform while they are measured. Any exception is noted with the results. - The explainer and the animations are schematic: they show where each clock starts and stops, not real timings. ## Latest results > **Latest results:** Workflow SDK v5.2.0 compared with v4.8.12, measured October 8, 2026. We run these benchmarks regularly and publish updated results here. At p75, v5.2.0 is as fast as or faster than v4.8.12 on every benchmark. The largest change is in long runs: 1,000 steps into a run, v5.2.0 adds 70 ms between steps where v4.8.12 adds 1,748 ms. Both versions ran at the same time and shared the platform. The exception is the 1,020-step benchmark on v4: one of its five runs ran alongside v5.2.0's, and the other four ran on their own afterwards, the same day with the same configuration. Each version ran the sequential workflow 10 times, the agent stream 10 times, the fan-out 25 times, the resume benchmark 25 times, and the 1,020-step run 5 times. ![Dot plot of how many times faster the newer version is than the older one at p75 on each benchmark, on a log scale. The full values are in the results table below.](/performance/v5.2.0/01-overview-light.png) ### Steps and long runs At p75, a five-step workflow reaches its first step in 172 ms on v5.2.0 and 418 ms on v4.8.12, and v5.2.0 adds 109 ms between steps where v4.8.12 adds 618 ms. Through a 1,020-step run, step-to-step overhead on v4.8.12 grows from 622 ms in steps 1 to 20 to 1,748 ms in steps 1,001 to 1,020, at p75. On v5.2.0 it is 110 ms and 70 ms. The median run takes 21.0 min on v4.8.12 and 3.0 min on v5.2.0. ![Three charts comparing the two versions: time to first step and step-to-step overhead for a five-step workflow, step-to-step overhead through a 1,020-step run at p75, and the time to finish all 1,020 steps. The full values are in the results table below.](/performance/v5.2.0/02-sequential-light.png) ### Fan-out At p75, v5.2.0 starts the first of 64 branches in 360 ms against 478 ms on v4.8.12, then starts the slowest branch at about the same time (996 ms against 972 ms) and joins in 448 ms against 464 ms. ![Dumbbell chart of a 64-branch fan-out at p75, comparing the two versions for the first branch to start, the slowest branch to start, and the join. The full values are in the results table below.](/performance/v5.2.0/03-fanout-light.png) ### Resuming a workflow At p75, a workflow waiting on a hook resumes in 425 ms on v5.2.0 against 699 ms on v4.8.12. ![Dumbbell chart of time to resume after 1 s idle at p50 and p75, comparing the two versions. The full values are in the results table below.](/performance/v5.2.0/04-resume-light.png) ### Streams The agent produced its 2,593-chunk response over 52.4 s. On v4.8.12 the writer step manages about 12 chunks per second, so the response takes 226.1 s to reach the reader. v5.2.0 writes streams over a WebSocket by default, keeps up at about 49 chunks per second, and delivers the response in 53.5 s. At p75, a chunk reaches the reader in 102 ms on v5.2.0 against 172 ms on v4.8.12. v5.2.0 had a chunk delayed by over 1 s in 0 of 10 runs, against 6 of 10 on v4.8.12. On the Vercel World, [`WORKFLOW_STREAMS_TRANSPORT`](/docs/configuration/worlds#workflow_streams_transport) sets the transport for stream writes. ![Three charts of a replayed agent stream comparing the two versions: the time to deliver the whole response against the recording's length, chunk trip time at p50 and p75, and the number of runs with a chunk delayed by over 1 s. The full values are in the results table below.](/performance/v5.2.0/05-streams-light.png) ### All results Every measured value from the latest results. Percentiles follow the [methodology](#methodology) above. | Metric | v4.8.12 | v5.2.0 | Change | Samples per version | | ----------------------------------------------- | -------: | ------: | -----------: | ------------------: | | **Sequential workflow, 5 steps, 10 runs** | | | | | | Time to first step, p75 | 418 ms | 172 ms | 2.4× faster | 10 runs | | Time to first step, slowest run | 451 ms | 253 ms | 1.8× faster | 10 runs | | Step-to-step overhead, p75 | 618 ms | 109 ms | 5.7× faster | 40 gaps | | Step-to-step overhead, p99 | 777 ms | 327 ms | 2.4× faster | 40 gaps | | **Long run, 1,020 steps, 5 runs** | | | | | | Step overhead, steps 1 to 20, p75 | 622 ms | 110 ms | 5.7× faster | 95 gaps | | Step overhead, steps 101 to 120, p75 | 719 ms | 74 ms | 9.7× faster | 95 gaps | | Step overhead, steps 1,001 to 1,020, p75 | 1,748 ms | 70 ms | 25.0× faster | 95 gaps | | Whole run, median wall time | 21.0 min | 3.0 min | 7.0× faster | 5 runs | | **Fan-out, 64 parallel branches, 25 runs** | | | | | | First branch starts, p75 | 478 ms | 360 ms | 1.3× faster | 25 runs | | Slowest branch to start, p75 | 972 ms | 996 ms | No change | 25 runs | | Join, p75 | 464 ms | 448 ms | No change | 25 runs | | **Resume after 1 s idle, 25 runs of 5 resumes** | | | | | | Time to resume, p50 | 619 ms | 383 ms | 1.6× faster | 125 resumes | | Time to resume, p75 | 699 ms | 425 ms | 1.6× faster | 125 resumes | | **Agent stream, 52.4 s recording, 10 runs** | | | | | | Delivery, median wall time | 226.1 s | 53.5 s | 4.2× faster | 10 runs | | Chunk trip time, p75 | 172 ms | 102 ms | 1.7× faster | 25,930 chunks | --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: "What's new in v5?" description: Workflow SDK 5.0 highlights, breaking changes, and how to upgrade from 4.x. docs_index: /llms.txt lastUpdated: 2026-10-21 type: guide summary: See what changed in Workflow SDK 5.0 and how to move an app from 4.x. related: - /docs/getting-started - /docs/configuration - /docs/foundations/cancellation --- > For an index of all documentation, see [/llms.txt](/llms.txt). We recommend upgrading to v5 to get all of the performance, cost, and feature improvements listed below. Install the migration skill, then tell your agent to migrate your app from Workflow SDK v4 to v5. ```bash npm install workflow@latest npx skills add https://github.com/vercel/workflow --skill migrating-workflow-v4-to-v5 ``` **Prompt for coding agents** Upgrade this app from Workflow SDK v4 to v5 using the migrating-workflow-v4-to-v5 skill. Bump \`workflow\` and every \`@workflow/\*\` dependency to ^5.0.0, apply the skill's mechanical rewrites, and report the behavior changes it flags instead of silently changing them. Then verify: reinstall, rebuild, typecheck, run the test suite, and execute one workflow end to end. > Workflow SDK v4 remains installable as `workflow@4` and receives stability > fixes. Its documentation lives at [/v4/docs](/v4/docs). ## Highlights ### Faster and cheaper runs ![Dot plot of how many times faster v5.2.0 is than v4.8.12 at p75 on each benchmark, on a log scale. Every benchmark is faster or unchanged.](/performance/v5.2.0/01-overview-light.png) At p75, v5.2.0 is as fast as or faster than v4.8.12 on every benchmark. The largest change in v5 has no API surface: the runtime does far less work per unit of progress. The time between calling `start()` and your first step body executing is now less than half of what it was in v4. This is made possible by many smaller optimizations: **A workflow invocation now does as much as it can in a single pass.** In 4.x, progress was largely deferred to the queue: an invocation would execute a step, hand back to the queue, and let a fresh invocation pick up the next one. v5 creates and executes several steps inline per suspension, in parallel, and only uses the queue for a wait, a hook, or when the function approaches its timeout. **The runtime avoids waiting on the persistence layer where it can determine that it is safe for your workload.** The runtime skips many API calls when they aren't needed, such as requesting the event log on a run's first invocation. Step creation is folded into step execution rather than being its own round trip. The inline loop consumes the event-log delta from the previous step's write instead of re-listing events. Each optimization is gated on specific runtime conditions and can be turned off individually. See [Runtime tuning](/docs/configuration/runtime-tuning). **The workflow VM is kept alive across inline steps.** Within one invocation, a step- or attribute-driven suspension keeps the live VM and hydrated state, including when hooks or waits are open or created at the same boundary, so the next iteration appends only the newly written events instead of rebuilding the sandbox and replaying the whole log. Step inputs made of plain data or standard built-ins keep this fast path; see [`WORKFLOW_RETAINED_VM`](/docs/configuration/runtime-tuning#workflow_retained_vm). **Replay no longer downloads recorded step inputs.** Replay recomputes step arguments by re-running workflow code, so it reads the event log without the `input` of each step. For workflows that pass growing state into their steps, this removes the part of the replay transfer that grew quadratically with the run. Worlds opt in through [`resolveData: 'skip-step-inputs'`](/docs/api-reference/workflow-runtime/world/storage#eventslist), and Vercel Workflows supports it. **Resuming a hook takes one round trip instead of two.** `resumeHook()` writes the `hook_received` event and dispatches the queue message concurrently, with a `(runId, resumeId)` dedup constraint keeping the two writers converging on exactly one event. See [Resilient hook resumption](/docs/changelog/resilient-resume). **A suspension's writes go out as one batch.** The `step_created` and `wait_created` events a suspension produces are folded into a single durable write with a per-event outcome, instead of one request each, and a fan-out's inline step bodies start straight off that commit rather than each claiming its step first. This engages on Worlds that implement the batch API and can be turned off with [`WORKFLOW_BATCH_TRANSITIONS=0`](/docs/configuration/worlds#workflow_batch_transitions). See [Batched event writes](/docs/changelog/batched-event-writes). **Concurrent writers no longer compete for a position in the log.** Each event's position used to be claimed by the write that filled it, so a wide fan-out serialized on that claim. Positions are now handed out ahead of the commit, which is what makes the fan-out above cheap. The cost is a position whose writer dies, which the backend closes with a `noop` event that replay steps over. Nothing about this is visible from workflow code; [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) is the kill switch, and existing runs keep the scheme they were created on. **Payloads are compressed** before they are encrypted and sent to the API. Repetitive payloads compress heavily; AI token streams average around 80% smaller. That is less stored data and less to move over the network. On [Vercel Workflows](/worlds/vercel) these benefits compound to reduce compute costs by up to 80% for workflows made of many small steps, and storage cost by up to 70%, depending on the workload. Depending on your setup, you may see similar gains using self-hosted or third-party Worlds. How much work fits in one invocation now scales with the function's own limit: the inline budget is derived from the runtime deadline the World reports, so raising `maxDuration` widens it without configuration. See [`WORKFLOW_V2_TIMEOUT_MS`](/docs/configuration/runtime-tuning#workflow_v2_timeout_ms). Benchmarks on Vercel compare v5.2.0 with v4.8.12: ![Three charts: v5.2.0 reaches the first step and moves between steps sooner than v4.8.12; through a 1,020-step run, step-to-step overhead on v4.8.12 grows toward the end while v5.2.0 stays flat; and v5.2.0 finishes all 1,020 steps several times sooner.](/performance/v5.2.0/02-sequential-light.png) Step-to-step overhead stays flat through a 1,020-step run on v5.2.0, while on v4.8.12 it grows with the event log. ![Three charts of a replayed agent stream: v5.2.0 delivers the response in about the recording's length while v4.8.12 takes about four times as long, v5.2.0 has a shorter chunk trip time, and no v5.2.0 run had a chunk delayed by over 1 s.](/performance/v5.2.0/05-streams-light.png) v5.2.0 writes streams over a WebSocket by default and keeps pace with a recorded agent stream that v4.8.12 falls behind. See [Performance](/docs/performance) for how these benchmarks are measured, the full results, and their limits. ### Multi-region support Workflow run IDs are now tagged with the compute region they were created in, or the region specified when calling `start()`. Worlds can use this to pin execution to that region. This is now the default when deploying to Vercel, and run data will automatically be co-located with the compute region that the run was started in. See [Multi-region on the Vercel World](/worlds/vercel#multi-region) for how automatic pinning works and how to [select a region explicitly](/worlds/vercel#explicit-region-selection), and [Building a World](/worlds/building-a-world) for adding regional placement to a custom World. ### Inflight cancellation `AbortController` and `AbortSignal` are now serializable, and signals can be passed into steps, so a workflow can abandon in-flight work without waiting for a step to finish. ```typescript lineNumbers import { sleep } from "workflow"; export async function raceWithTimeout() { "use workflow"; const controller = new AbortController(); // [!code highlight] const result = await Promise.race([ fetchData(controller.signal), // [!code highlight] sleep("10s").then(() => null), ]); if (result === null) { controller.abort(); // [!code highlight] } return result; } async function fetchData(signal: AbortSignal) { "use step"; const response = await fetch("https://api.example.com/data", { signal }); return response.json(); } ``` The abort reaches a step that is already executing, not just the next one to start, so a `fetch()` in flight when the controller aborts is torn down. Cancellation stays cooperative: a step that ignores its signal still runs to completion. Cancelling from the outside gained detail too: [`run.cancel()`](/docs/api-reference/workflow-api/get-run) accepts a `cancelReason`, recorded on the cancellation event and shown in the run detail view. And cancellation now works in bulk: `workflow cancel` takes `--status pending|running` and `--workflowName` to cancel a batch in one operation, and the runs table in the web UI cancels every selected run in a single request. See [CLI and web UI](/docs/configuration/cli-and-web-ui). See [Cancellation](/docs/foundations/cancellation), [How cancellation works](/docs/how-it-works/cancellation), and the [in-flight cancellation changelog](https://vercel.com/changelog/workflow-sdk-now-supports-inflight-cancellation). ### Run attributes Attach string metadata to a run with [`setAttributes()`](/docs/api-reference/workflow/set-attributes), or seed it at creation with the `attributes` option of [`start()`](/docs/api-reference/workflow-api/start), then search and filter runs by `key=value`. Attributes were available in beta and are generally available in v5 under their final names: the `experimental_setAttributes` alias is gone. Some attributes are set for you: a run started from inside another workflow or step is automatically tagged with the reserved `$parentRunId` and `$rootRunId` attributes, so a whole chain or fan-out of related runs can be found with a single attribute filter. Vercel Observability can search runs by attribute. `workflow inspect` and the local web UI read them from `world.analytics` on any World that implements it. See [Attributes](/docs/observability/attributes). ### Richer serialization Everything that crosses a workflow/step boundary is serialized. In v5, built-in errors such as `TypeError` preserve their type, message, stack, and `cause` chain through `WorkflowRunFailedError.cause`. Plain user-defined Error subclasses preserve their name but revive as generic errors unless they implement [custom serialization](/docs/foundations/serialization). Bundled module copies can still have distinct constructors, so use SDK error `.is()` helpers instead of `instanceof`: ```typescript lineNumbers import { FatalError } from "workflow"; import { WorkflowRunFailedError } from "workflow/errors"; import { getRun } from "workflow/api"; try { await getRun(runId).returnValue; } catch (error) { if ( WorkflowRunFailedError.is(error) && FatalError.is(error.cause) // [!code highlight] ) { console.error(error.errorCode, error.cause.message); } } ``` Workflow function references and [`Run`](/docs/api-reference/workflow-api/get-run) handles are serializable too, so a step can receive a workflow function to `start()` or a run handle to await. And when a value cannot cross a boundary, the failure is precise instead of generic: dedicated [`SerializationError`](/docs/api-reference/workflow-errors) and structured context-violation errors name the offending value and where it was used. See [Serialization](/docs/foundations/serialization). ### A redesigned trace viewer The trace viewer has been rebuilt with a visible timeline, a minimap, pan, zoom, debug functionality, a new JSON viewer, keyboard navigation, and more. Vercel Observability uses this trace viewer for all runs, v4 included, but with v5, you get the same new design for self-hosted UI and local debugging. See [Observability](/docs/observability). The local tooling around it grew as well: on [Nitro](/docs/getting-started/nitro) the dev server has the web UI built in at `/_workflow`, `workflow inspect runs` accepts `--since`/`--until` listing windows, run lookups by name search past the backend's default 24-hour window, and Worlds can surface their own run fields in `inspect` output. For example, the Vercel World shows each run's region. On any other framework, `createWorkflowWebHandler()` from `@workflow/web/handler` serves the same UI as one `Request` to `Response` handler under a base path of your choosing. Each run also carries more of the infrastructure it ran on. A step attempt records the compute instance that executed it, surfaced as **Compute Instance ID** in the run sidebar and as a `faas.instance` span attribute on flow and step spans, so a run that behaves oddly can be correlated with one warm instance. The sidebar also shows a copyable **Request ID** for looking the invocation up in your platform's logs. ### Custom hook token retention A [Hook](/docs/foundations/hooks) token is normally reserved only while its workflow is running. Pass `experimental_minRetention` to [`createHook()`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) to keep the token unavailable for at least a given duration after the Hook is created, so a duplicate request arriving after the original run finished still collides instead of starting fresh work. This allows for durable [run idempotency](/docs/foundations/idempotency#run-idempotency) with custom durations. All three first-party Worlds now implement it: Vercel accepts up to 30 days, and the Local and Postgres Worlds default to the same ceiling (see [`WORKFLOW_LOCAL_HOOK_RETENTION_LIMIT_DAYS`](/docs/configuration/worlds) and its Postgres equivalent). A retained Hook remains readable with [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) after its run ends, but cannot be resumed. ### Also new in 5.0 - **`start()` from inside a workflow.** Spawn a child run or hand off to a new run directly in a workflow function, without wrapping it in a step. See [Starting workflows](/docs/foundations/starting-workflows). - **Hook token takeover.** Pass `experimental_force: true` to [`createHook()`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) when the newest run should own a token another run still holds. The previous owner is woken and its `await hook` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), while `resumeHook()` callers, including one already in flight, are routed to the new owner without noticing. Supported by all three first-party Worlds. - **Stronger hook coordination.** `hook.getConflict()` resolves with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run) rather than a bare `{ runId }`, so a duplicate can `await conflict.status`, `await conflict.returnValue`, or `await conflict.cancel()` directly. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). - **Support for more frameworks.** [React Router](/docs/getting-started/react-router) (v7 and v8, via Nitro) and [NestJS](/docs/getting-started/nestjs) are now supported. - **A misrouted delivery no longer fails a run.** Runs are pinned to the deployment that created them. A delivery that arrives at a different deployment is now re-routed to the pinned one with backoff instead of failing, and only gives up with the new [`DEPLOYMENT_MISMATCH`](/docs/errors/deployment-mismatch) error once the recovery budget is spent. Nothing executes on the wrong deployment while this happens. In 4.x the same situation surfaced as an unexplained decryption failure. - **A run cannot be forked across environments.** `start()` stamps the environment it was called from onto the queue message, and a deployment refuses a delivery whose run was created in a different environment. Previously a preview client and a production deployment could each hold half of one run ID. - **An experimental QuickJS VM engine.** Set [`WORKFLOW_VM=quickjs`](/docs/configuration/runtime-tuning#workflow_vm) to run workflow functions in a QuickJS VM compiled to WebAssembly instead of `node:vm`, for platforms that do not provide `node:vm`. Replay semantics are identical, but the available globals are not: check the differences before switching an existing deployment. By default, an encrypted QuickJS run that passes 1,000 events resumes from a VM-memory snapshot instead of replaying its full event log (see [`WORKFLOW_SNAPSHOT_THRESHOLD`](/docs/configuration/runtime-tuning#workflow_snapshot_threshold)). - **Experimental dynamic workflows.** Pass workflow source as a string to `start()` to run orchestration assembled after deployment over steps already deployed with your app. The code is stored with the run (encrypted on Vercel), so replays always execute the code the run started with. Off unless the deployment sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`. See [Dynamic Workflows](/docs/advanced/dynamic-workflows). - **A WebSocket transport for event and stream writes** on the Vercel World, which sends a run's event writes and stream writes over a socket instead of one HTTP request each. It is the default; set [`WORKFLOW_EVENTS_TRANSPORT=http`](/worlds/vercel#workflow_events_transport) or [`WORKFLOW_STREAMS_TRANSPORT=http`](/worlds/vercel#workflow_streams_transport) to opt out. Tracing is unaffected: each write still emits an `http POST` client span, synthesized around the frame, with the transport on `workflow.events.transport`. - **`await run.returnValue` waits instead of polling.** Reading a run's result long-polls the World until the run reaches a terminal status, rather than asking again on a fixed interval. A result is observed as soon as it exists, and an idle wait costs one open request instead of a request per tick. Worlds that do not implement the long poll keep the interval. - **An event arriving mid-replay no longer fails the run.** A hook resume or step completion landing while a replay is in flight used to be able to fail it with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). Writes now come back with the events the replay had not seen, and the event is held for whichever part of the workflow awaits it. A run only fails when the log is genuinely missing a position. ## Breaking changes ### Application code | Change | What to do | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `runStep` removed from `workflow/api` | Call your step function directly; the compiler routes it through the step runtime. | | `hook.getConflict()` resolves with a `Run` | Replace `conflict.runId` round trips through `getRun()` inside a step with the accessors on `conflict` directly. `conflict.runId` still works. | | `hook.metadata` is a Promise on hooks returned by `getHookByToken()` and `resumeHook()` | Write `await hook.metadata` where you read it. Hydrating metadata can add network round trips, and those are now paid only by code that reads it, so the lookup itself is a single read. The `Hook` type is exported from `workflow/api`; the World-level record from `world.hooks.getByToken()` is unchanged. See [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). | | `experimental_setAttributes` removed | Import `setAttributes` instead, and `SetAttributesOptions` in place of `ExperimentalSetAttributesOptions`. The deprecated aliases are gone. | | [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers), and [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) are async | They resolve a `Promise` now, so await the call before reaching for anything on it: `const world = await getWorld();` then `await world.start?.();`. This mainly affects the `instrumentation.ts` bootstrap that starts a World with background workers, such as the [Postgres World](/worlds/postgres#starting-the-world). Under TypeScript the old shape fails the build; in plain JavaScript it does not, and `.start` reads as `undefined` on a promise, so the worker never starts and nothing is logged. Writing `await getWorld()` is also valid on 4.x, so the change can be made before upgrading. | | Duplicate step or workflow IDs fail the build | 4.x resolved collisions across non-exported workspace files last-write-wins. If you start encountering build failures after upgrading, rename the colliding functions. | | The generated bundles are renamed, and the step route is gone | Only affects apps that wire the output of `workflow build` into their own server instead of using a framework integration. `flow.js` and `webhook.js` are now `flow.mjs` and `webhook.mjs` with named exports only, so a default import resolves to `undefined`. `step.js` became `__step_registrations.mjs`, an internal module that `flow.mjs` imports: delete the `POST /.well-known/workflow/v1/step` route rather than repointing it, because the flow handler now serves step deliveries too. See [Framework integrations](/docs/how-it-works/framework-integrations). | | Default trace mode is `linked` | Update dashboards that assume one trace per run, or set `WORKFLOW_TRACE_MODE=continuous`. | | The event-creation precondition guard is gone | `WORKFLOW_PRECONDITION_GUARD` no longer exists, and no World in the SDK rejects a write for a stale snapshot. Remove the variable if you set it. A replay that is behind now learns what it missed from the write it makes next instead of from a rejection, and [`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error) remains only for a custom World that would still rather refuse. | | Event IDs are slot numbers, not ULIDs | An event ID is now its 1-based position in the run's log (`evnt_00000000000000000000000042`). It is unique only within a run, so pair it with the `runId` as a key, and it carries no timestamp: decoding one yields the Unix epoch rather than a creation time, so read `createdAt` off the event instead. Other entity IDs are unchanged. See [Event IDs](/docs/how-it-works/event-sourcing#event-ids). | | A per-run event limit is enforced | The World supplies the ceiling; for the Vercel World see [Workflow run limits](https://vercel.com/docs/workflows/pricing#workflow-run-limits). A run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows), or run long-running workflows on the QuickJS VM with snapshotting, which is [exempt from the default ceiling](/docs/errors/max-events-exceeded). [`WORKFLOW_MAX_EVENTS`](/docs/configuration/runtime-tuning#workflow_max_events) tunes the ceiling on the Local and Postgres Worlds; the Vercel World's is service-owned and the variable does not override it. | | Stream writes flush the first chunk immediately | The leading-edge flush window defaults to `0` instead of 10ms. Restore a window with `streamFlushIntervalMs` or `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`. | | The workflow sandbox is stricter about nondeterminism | `WeakRef`, `FinalizationRegistry`, `Atomics.waitAsync`, and async `WebAssembly` compilation are no longer available inside workflow functions, and `crypto.subtle.digest` computes synchronously (same results, deterministic timing). Move code that needs them into a step. | | `Date()` without `new` returns a string inside workflow functions | This matches the language spec, and 4.x returned a `Date` object. Use `new Date()` where you need the object. Subclassing `Date` now works, so libraries like `TZDate` keep their identity across the sandbox boundary. | | `NestLocalBuilder` moved out of `@workflow/nest` root | Import it from `workflow/nest/builder`, so `WorkflowModule` no longer pulls the build toolchain into the runtime bundle. `NestVercelBuilder` lives at `workflow/nest/vercel-builder`. | | `workflow/internal/private` and `@workflow/core/private` removed | These were never public API. The compiler no longer emits imports from them, so regenerate build output rather than importing them yourself. | | The legacy trace viewer is gone from `@workflow/web-shared` | Only affects apps embedding the observability UI. `RunTraceView` and `WorkflowTraceViewer` are removed, and `NewTraceViewer` is now `TraceViewer` (module path `trace-viewer`). `Span`, `SpanEvent`, and `Trace` are still exported from the package root. | | `startServer()` from `@workflow/web/server` resolves a `srvx` `Server` | Only affects apps that self-host the observability UI with it. Stop the server with `await server.close()`, and reach the Node `http.Server` at `server.node.server` to listen for its events. The server now also answers conditional and range requests and compresses responses. | Runs created on 4.x keep executing on the deployment that created them, so upgrading a deployment does not migrate in-flight runs. One storage caveat is worth knowing about: failed runs stored by `@workflow/world-postgres` before the upgrade read back with `error: undefined`, because the payload lives in the legacy `error` text column rather than `errorJson`. ## If you maintain a World The World interface, which defines the storage, queue, streaming, and analytics contract that a Workflow SDK deployment runs against, also changed in v5. Those changes are not visible from application code. If you implement `World` yourself or maintain a build integration that compiles workflow files, upgrade it alongside the SDK. See [Upgrading a World to v5](/worlds/upgrading-to-v5) for the full interface delta and the contract changes that affect existing implementations. There is a separate skill for that job because none of it applies to application code: ```bash npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5 ``` Applications on the [Vercel](/worlds/vercel), [Local](/worlds/local), and [Postgres](/worlds/postgres) Worlds need nothing from that page: those implementations ship with the SDK and are already on the v5 spec. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: Dynamic Workflows description: Start a workflow run from source code that was not part of your build. docs_index: /llms.txt lastUpdated: 2026-10-21 type: conceptual summary: Pass workflow source to start() to run orchestration whose shape is only known after deployment. prerequisites: - /docs/foundations/starting-workflows - /docs/how-it-works/code-transform related: - /docs/api-reference/workflow-api/start - /docs/how-it-works/encryption - /docs/configuration/runtime-tuning --- > For an index of all documentation, see [/llms.txt](/llms.txt). > Dynamic workflows are **experimental** and **off by default**. The API may change without a major version bump. A deployment must opt in with `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`, dynamic runs can only start on the current deployment, and the World must support dynamic-source storage. See [Enabling dynamic workflows](#enabling-dynamic-workflows) and [World support](#world-support). > Dynamic source runs with the **full privileges of your deployment's functions**. It can read every environment variable, use the network and the filesystem, and call any step in the deployment. `experimental_dynamic.steps` is not a security boundary. Only pass source you would merge into your codebase. See [Security](#security). Normally a workflow function is compiled into your build: the [code transform](/docs/how-it-works/code-transform) rewrites every `"use workflow"` function, the build bundles them, and `start()` names one by importing it. A dynamic workflow skips that. You hand `start()` a string of JavaScript, and it runs — no build, no deploy: ```ts import { start } from 'workflow/api'; import { fetchUser, sendEmail } from './steps'; const run = await start( ` async function workflow(input) { "use workflow"; const user = await steps.fetchUser(input.userId); await steps.sendEmail(user.email); return { ok: true }; } `, [{ userId: 'user_123' }], { experimental_dynamic: { steps: { fetchUser, sendEmail }, }, } ); console.log(await run.status); // 'running' ``` Only the *orchestration* is dynamic. Every step the source calls was deployed with your app, and `experimental_dynamic.steps` names the ones it calls by alias. That map does not stop source from reaching other steps; see [Security](#security). There is no way to define a new step from source. ## When to use this Reach for dynamic workflows when the **shape** of the orchestration is only known after you deploy, and the source comes from code you trust as much as your own: - **Orchestration your application assembles** from reviewed templates, over a fixed set of deployed steps. - **Experiments** — try a new composition of existing steps without shipping a build. Dynamic workflows are not a way to run code written by your end users or generated by a model from their input. That source would run with your deployment's privileges; see [Security](#security). If your workflows are known at build time, use a normal workflow function. It has better types, better errors, no source validation, and no size limits. ## Enabling dynamic workflows Dynamic workflows are off unless the deployment sets: ```bash WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1 ``` Only `1` or `true` (case-insensitive) enables them; any other value, or no value, leaves them off. The runtime reads the variable where workflows execute, when it needs it, so set it on the deployment or dev server rather than at build time. It controls three things: - **Starting.** `start()` with source throws before it contacts the World, creates a run, or enqueues anything unless this process has opted in. - **Delivery.** When a dynamic run reaches a deployment that has not opted in, the runtime does not execute its stored code. It fails the run with a `RUNTIME_ERROR` rather than retrying it. - **Health check.** A deployment advertises dynamic support in its [health check](/docs/api-reference/workflow-runtime/health-check) only when it has opted in. ### Same deployment only A dynamic run must execute on the deployment that started it. `start()` rejects a dynamic start whose target differs from the current deployment. That includes an explicit `deploymentId` for another deployment, `deploymentId: 'latest'` when it resolves to a different deployment, and any concrete target when the current deployment cannot be determined. The rejection happens before any capability check, key lookup, upload, run creation, or queue message. ## What the source can use Dynamic source has no imports. Instead, the generated code predefines a small runtime surface: | Binding | What it is | | ------------ | --------------------------------------------------------------------------------------------------------------------- | | `steps` | Frozen object of the aliases you passed in `experimental_dynamic.steps`. Calling one dispatches that registered step. | | `sleep` | The [durable sleep](/docs/api-reference/workflow/sleep) primitive. | | `createHook` | The [hook](/docs/foundations/hooks) primitive, for waiting on an external signal. | The source also runs inside the normal deterministic workflow VM, so the usual [workflow globals](/docs/api-reference/workflow-globals) — `Date`, `Math.random`, `crypto`, `URL`, `TextEncoder`, `structuredClone`, and the rest — are available with the same determinism guarantees as a static workflow. Dynamic source exposes only the small set of primitives injected by its generated wrapper. `createWebhook()` also needs the static workflow module's URL and metadata helper, and `getWritable()` needs its workflow-stream helper, so neither is currently injected into dynamic source. Use `createHook()` with server-side `resumeHook()`, and perform streaming through registered steps or a statically compiled workflow. Here is a longer example using a timer and a hook to wait for an approval: ```ts import { start } from 'workflow/api'; import { sendEmail } from './steps'; const run = await start( ` async function workflow(input) { "use workflow"; await sleep("15m"); const approval = createHook({ token: input.approvalToken }); const result = await Promise.race([ approval, sleep("1d").then(() => ({ approved: false, timedOut: true })), ]); if (result.approved) { await steps.sendEmail(input.email); } return result; } `, [{ userId: 'user_123', email: 'ada@example.com', approvalToken: 'approval-req_01J...', }], { experimental_dynamic: { steps: { sendEmail }, }, } ); ``` Supply a unique, deterministic approval token from the caller. The workflow must recreate the same token during replay, while the external service needs that token to call `resumeHook()`; do not use a tenant or user ID alone when concurrent runs can overlap. ## Rules for the source `start()` validates the source before it writes anything, so a definition that could never run fails at the call site rather than on a queue delivery: - It must declare `async function workflow(...)`. Pass `experimental_dynamic.exportName` to use a different name; export names may contain letters, digits, and `_`, cannot start with a digit, and are at most 64 characters. - The function's first statement must be the `"use workflow"` directive. - No `import` or `export`. Reach steps through `steps`, not through modules. - JavaScript only — no TypeScript syntax, no npm dependencies, no bundling. - No inline `"use step"` functions. Steps come from `experimental_dynamic.steps`. - At most 128 KB of source. - On Vercel, the run's execution context is limited to 2,048 bytes of JSON, and the `dynamicWorkflow` metadata below counts against it. That leaves room for roughly 30 step aliases, depending on how long the aliases and step IDs are. A start that exceeds it fails before anything is written. Everything a static workflow must obey still applies: the body has to be [deterministic](/docs/foundations/workflows-and-steps), and any side effect belongs in a step. ## Workflow IDs You do not choose the workflow ID. It is derived from the source and its step bindings: ``` workflow//dynamic/// ``` Two consequences worth knowing: - **The same definition always gets the same ID.** Runs of one generated workflow group together in [observability](/docs/observability) and share a queue topic, even across processes. - **A caller cannot claim an ID.** Because the hash covers the source *and* the step bindings, arbitrary source cannot be made to run under a static workflow's name — or under another definition's. Changing the source, or pointing an alias at a different step, produces a different workflow. ## How the code is stored A dynamic run's workflow function is not in your deployment's bundle, so the run carries its own compiled workflow code — and replaying the run means replaying *that* code, not whatever your deployment contains now. That code uses the same serialization path as workflow inputs. It is compressed when the run protocol supports compression and compression is worthwhile, and encrypted when the World supplies run key material (see [Encryption](/docs/how-it-works/encryption)). Vercel's supported configuration provides encrypted storage; the Local and Postgres Worlds store it in plaintext. Retention and deletion apply whether the stored bytes are plaintext or ciphertext. When a run has key material, or was started with encryption, a delivery only executes code stored in the run's symmetric `encr` envelope. It refuses plaintext and sealed (`encp`) payloads and fails the run. Encryption keeps the code confidential; it does not prove who wrote it. See [Security](#security). On Vercel, durable workflow code storage is ref-backed on the run. The definition's size changes only how those bytes reach the backend: - **Small definitions** (the overwhelming majority) ride inline in the `run_created` request frame. The backend materializes those bytes into the run's ref-backed storage, with no upload request from `start()`. - **Larger definitions** are uploaded first, and `run_created` carries the resulting reference. This costs one extra request at `start()`. Both paths are transparent — there is nothing to configure. Here, “inline” describes request transport, not a second durable storage shape. Alongside the serialized code, the run records small plaintext metadata on `executionContext.dynamicWorkflow`: the source hash, the export name, and the alias-to-step-ID map. That is what lets a run be identified as dynamic without decoding the source. It is plaintext even when the code is encrypted, so anyone who can read the run can see which step IDs it was given and the aliases they were given under. In the [observability UI](/docs/observability), a dynamic run's detail view shows its stored code in a **Workflow Code** section, behind the same decrypt action as the run's input and output. **Replay Run** is unavailable for dynamic runs. To run the definition again, call `start()` with the same source. ## World support Dynamic workflows need a World that can store the run's workflow code. | World | Support | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Vercel](/worlds/vercel) | Encrypted, ref-backed storage (small definitions transported inline; large definitions uploaded first). If dynamic-source storage is not enabled for the project, the backend rejects the run's creation and `start()` throws. | | [Local](/worlds/local) | Stored in plaintext on the run record in the local filesystem store. | | [Postgres](/worlds/postgres) | Stored in plaintext on the run row. | | Others | Supported when the World declares [`capabilities.dynamicWorkflowCode`](/worlds/building-a-world). | After the opt-in and same-deployment checks, `start()` fails a dynamic start on a World that does not declare `capabilities.dynamicWorkflowCode`. On Vercel, `start()` then validates the final execution context against the 2,048-byte limit. All of this happens before serializing or uploading code, creating an event, or publishing a queue message. ## Security > Dynamic source is **trusted application code** with the full privileges of your deployment's functions. The workflow VM is a determinism sandbox, not a security sandbox. Code in it can reach the host process: it can read every environment variable, use the network and the filesystem, and call any step registered in the deployment with any arguments. - **`steps` is not a boundary.** The `steps` object contains only the aliases you passed and is frozen, so ordinary code that calls `steps.somethingElse()` fails the run instead of dispatching a step it was not given. Code that is trying to reach other steps, or the host, can. - **Only start source you would merge.** Do not build source from end-user input, and do not run model output generated from untrusted input. Either one gives whoever controls that input your deployment's privileges. - **Opting in is a deployment decision.** A deployment that sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS` executes stored code for any dynamic run it receives. Anyone who can start a workflow on it can run code with its privileges. - **Encryption gives confidentiality only.** Where the World encrypts stored code, it cannot be read at rest without the run's key, and a delivery refuses code that is not encrypted with that key. Anyone who can obtain the run's key can still write valid code, so encryption does not replace the opt-in. - **Plaintext Worlds turn storage write access into code execution.** The Local and Postgres Worlds store the code in plaintext. On an opted-in deployment, anyone who can write to the Postgres database or the local data directory can make every worker execute code of their choosing. - **The step map is readable.** `executionContext.dynamicWorkflow.steps` stores the alias-to-step-ID map in plaintext, so anyone with read access to the run sees the step IDs the source was given. Treat dynamic source the way you would treat code in a pull request: written or reviewed by someone you trust with the deployment. ## Limitations - Experimental — the API may change without a major version bump. - Off unless the deployment sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`. - Same-deployment starts only. - JavaScript only. No TypeScript syntax, npm dependencies, or bundling. - Steps must already be registered in the deployment; no runtime step registration. - No inline `"use step"` functions, `createWebhook`, or `getWritable`. - No caller-provided workflow IDs. - No **Replay Run** from the observability UI. - Parser-based validation checks JavaScript syntax and the required source/wrapper shape without executing it. It does not validate behavior, determinism, or intent. - On Vercel, roughly 30 step aliases fit the 2,048-byte execution-context limit. - Requires a World with dynamic-source storage. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: Advanced description: Features for workflows that go beyond the build-time execution model. docs_index: /llms.txt lastUpdated: 2026-10-21 type: overview summary: Explore features for cases that workflows compiled into your build do not cover. related: - /docs/foundations - /docs/how-it-works/code-transform --- > For an index of all documentation, see [/llms.txt](/llms.txt). These features build on the [foundations](/docs/foundations) for cases that workflows compiled into your build do not cover, such as orchestration whose shape is only known after you deploy. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: Chat Session Modeling description: Model WorkflowAgent chat sessions at different architectural layers to control state ownership and handle interruptions. docs_index: /llms.txt lastUpdated: 2026-10-21 type: guide summary: Choose between single-turn and multi-turn WorkflowAgent patterns for managing chat session state. prerequisites: - /docs/ai - /docs/foundations/workflows-and-steps related: - /docs/ai/message-queueing - /docs/ai/resumable-streams - /docs/foundations/hooks - /docs/api-reference/workflow/define-hook --- > For an index of all documentation, see [/llms.txt](/llms.txt). Chat sessions can be modeled at different layers of your architecture. The choice determines who owns message history, how long a workflow run stays active, and how clients reconnect after an interruption. Workflow 5 applications should use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for both patterns. ## Single-turn workflows Each user turn starts a new workflow run. The client or API owns conversation history and sends the complete `UIMessage[]` array with every request. ### Workflow Convert UI messages to model messages inside the workflow. `WorkflowAgent` writes durable `ModelCallStreamPart` values to the run stream. ```typescript title="workflows/chat.ts" lineNumbers import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow"; import { convertToModelMessages, type UIMessage } from "ai"; import { getWritable } from "workflow"; import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools"; export async function chat(messages: UIMessage[]) { "use workflow"; const agent = new WorkflowAgent({ model: "spacexai/grok-4.6", instructions: FLIGHT_ASSISTANT_PROMPT, tools: flightBookingTools, }); return agent.stream({ messages: await convertToModelMessages(messages), writable: getWritable(), }); } ``` ### API route Convert the durable model-call stream to AI SDK UI chunks at the response boundary: ```typescript title="app/api/chat/route.ts" lineNumbers import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow"; import { createUIMessageStreamResponse, type UIMessage } from "ai"; import { start } from "workflow/api"; import { chat } from "@/workflows/chat"; export async function POST(request: Request) { const { messages }: { messages: UIMessage[] } = await request.json(); const run = await start(chat, [messages]); return createUIMessageStreamResponse({ stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), headers: { "x-workflow-run-id": run.runId }, }); } ``` ### Client `WorkflowChatTransport` reconnects when the HTTP connection ends before the workflow stream finishes: ```tsx title="app/chat.tsx" lineNumbers "use client"; import { useChat } from "@ai-sdk/react"; import { WorkflowChatTransport } from "@ai-sdk/workflow"; import { useMemo } from "react"; export function Chat({ initialMessages }) { const transport = useMemo( () => new WorkflowChatTransport({ api: "/api/chat" }), [] ); const { messages, sendMessage } = useChat({ messages: initialMessages, transport, }); // Render messages and call sendMessage({ text }) from your form. } ``` Persist `UIMessage[]` in your application database. `WorkflowAgent.stream()` returns `ModelMessage[]`, but there is no general conversion from model messages back to UI messages with all UI metadata intact. Use the single-turn pattern when: - Your application already owns chat history - Each turn should run on the latest deployment - You want a simple request-to-run mapping - Approval responses or client-side tool results arrive as another message turn ## Multi-turn workflows A single workflow run can own the model-message history for the whole session. It waits on a Hook between turns, and external callers resume the Hook with the next message. The workflow run ID becomes the session identifier. ```typescript title="workflows/chat-session.ts" lineNumbers import { WorkflowAgent, type ModelCallStreamPart } from "@ai-sdk/workflow"; import { type ModelMessage } from "ai"; import { defineHook, getWorkflowMetadata, getWritable } from "workflow"; import { z } from "zod"; import { flightBookingTools, FLIGHT_ASSISTANT_PROMPT } from "./tools"; export const chatMessageHook = defineHook({ schema: z.object({ message: z.string() }), }); export async function chatSession(initialMessages: ModelMessage[]) { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); const hook = chatMessageHook.create({ token: workflowRunId }); const writable = getWritable(); let messages = [...initialMessages]; const agent = new WorkflowAgent({ model: "spacexai/grok-4.6", instructions: FLIGHT_ASSISTANT_PROMPT, tools: flightBookingTools, }); const maxTurns = 100; for (let turn = 0; turn < maxTurns; turn++) { const result = await agent.stream({ messages, writable, preventClose: true, sendFinish: false, }); messages = result.messages; // Do not accept a follow-up that this run has no remaining turn to process. if (turn === maxTurns - 1) break; const { message } = await hook; if (message === "/done") break; messages = [...messages, { role: "user", content: message }]; } return { messages }; } ``` Create the Hook once, outside the loop. Recreating the same token on every turn causes a Hook conflict. Intermediate turns use `preventClose: true` with `sendFinish: false`; the workflow closes the durable stream and emits the final UI `finish` when the run returns. ### Start and resume the session Use one route to create the run and a second route to deliver follow-up messages: ```typescript title="app/api/chat/route.ts" lineNumbers import { convertToModelMessages, type UIMessage } from "ai"; import { start } from "workflow/api"; import { chatSession } from "@/workflows/chat-session"; export async function POST(request: Request) { const { messages }: { messages: UIMessage[] } = await request.json(); const run = await start(chatSession, [await convertToModelMessages(messages)]); return Response.json({ runId: run.runId }); } ``` ```typescript title="app/api/chat/[runId]/message/route.ts" lineNumbers import { chatMessageHook } from "@/workflows/chat-session"; export async function POST( request: Request, { params }: { params: Promise<{ runId: string }> } ) { const { runId } = await params; const { message }: { message: string } = await request.json(); await chatMessageHook.resume(runId, { message }); return Response.json({ success: true }); } ``` ### Reconnect to the stream `WorkflowChatTransport` counts transformed UI chunks, while the durable stream stores raw `ModelCallStreamPart` values. Always replay the raw stream from index `0` and apply the UI cursor in `createModelCallToUIChunkTransform()`: ```typescript title="app/api/chat/[runId]/stream/route.ts" lineNumbers import { createModelCallToUIChunkTransform } from "@ai-sdk/workflow"; import { createUIMessageStreamResponse } from "ai"; import { getRun } from "workflow/api"; export async function GET( request: Request, { params }: { params: Promise<{ runId: string }> } ) { const { runId } = await params; const startIndex = Number( new URL(request.url).searchParams.get("startIndex") ?? "0" ); if (!Number.isSafeInteger(startIndex) || startIndex < 0) { return Response.json( { error: "startIndex must be a non-negative safe integer" }, { status: 400 } ); } const run = getRun(runId); const stream = run .getReadable({ startIndex: 0 }) .pipeThrough(createModelCallToUIChunkTransform({ uiStartIndex: startIndex })); return createUIMessageStreamResponse({ stream, headers: { "x-workflow-run-id": runId }, }); } ``` Persist `UIMessage[]` separately if the client must reconstruct user messages and UI metadata after a refresh. The workflow-owned `ModelMessage[]` history is the model's durable context, not a replacement for an application chat table. ### Persist and display the full session A multi-turn chat has three related records with different responsibilities: 1. **Workflow model history**: `ModelMessage[]` is the durable context sent back to the model on each turn. 2. **Workflow run stream**: `ModelCallStreamPart` values contain durable model and tool output for live delivery and reconnection. 3. **Application chat history**: `UIMessage[]` preserves user messages, display metadata, attachments, and application-specific parts. When the user sends the first message, start the run, store its run ID with the application chat record, and connect to the run stream. For each follow-up, optimistically add and persist the user `UIMessage`, then resume `chatMessageHook` with the corresponding text. After a refresh, load the persisted UI messages and reconnect through the stream route above using the last persisted UI chunk cursor. If you persist only user messages, replay model output from the beginning and merge by stable message IDs. Do not treat the run stream as the only chat database. `WorkflowAgent` deliberately stores raw model-call parts, and user messages resumed through a Hook are not automatically written to that output stream. Keeping the application history separate avoids synthetic stream markers and preserves UI information that cannot be reconstructed from `ModelMessage[]`. > The reconnect route replays raw `ModelCallStreamPart` values from index `0` because raw parts and transformed UI chunks do not have matching indexes. `uiStartIndex` prevents already-delivered UI chunks from being sent to the client again, but the server still transforms the earlier raw history. For very long streams, split conversations into bounded runs until WorkflowAgent exposes a persisted raw-to-UI cursor mapping. Use the multi-turn pattern when: - One workflow should own the session's model context - Backend events or other users need to inject messages through Hooks - Full-session tracing is more important than running every turn on the newest deployment - The application is prepared to manage a long-lived run and stream cursor ## Choosing a pattern | Consideration | Single-turn | Multi-turn | | --------------------- | ------------------------------ | --------------------------------------------------------------- | | State ownership | Client or application database | Workflow for model context; application database for UI history | | Deployment version | Latest deployment per turn | Deployment that started the run | | Message injection | Start another run | Resume a Hook | | Workflow complexity | Lower | Higher | | Workflow time horizon | One model turn | Hours or longer | | Observability scope | Per turn | Full session | **Multi-turn works well for new durable sessions.** The workflow owns model context, accepts messages from users and backend systems through the same Hook, and provides one full-session trace. **Single-turn works well when adapting an existing architecture.** If the application already manages message state and you want to adopt durable agents incrementally, one workflow run per turn requires fewer lifecycle changes and always uses the latest deployment. ## Multiplayer chat sessions The multi-turn pattern also supports messages from system events, external services, and multiple users. Every source resumes the same Hook; the workflow queues those messages and processes them between model turns. #### System event Scheduled tasks, background jobs, or database triggers can inject updates into an active conversation: ```typescript title="app/api/internal/flight-update/route.ts" lineNumbers import { chatMessageHook } from "@/workflows/chat-session"; export async function POST(request: Request) { const { runId, flightNumber, newStatus } = await request.json(); await chatMessageHook.resume(runId, { message: `[System] Flight ${flightNumber} status updated: ${newStatus}`, }); return Response.json({ success: true }); } ``` #### External service A third-party webhook can notify the conversation about an external event: ```typescript title="app/api/webhooks/payment/route.ts" lineNumbers import { chatMessageHook } from "@/workflows/chat-session"; export async function POST(request: Request) { const { runId, paymentStatus, amount } = await request.json(); if (paymentStatus === "succeeded") { await chatMessageHook.resume(runId, { message: `[Payment] Payment of $${amount.toFixed(2)} received. Your booking is confirmed.`, }); } return Response.json({ received: true }); } ``` #### Multiple users Multiple authenticated users can participate in the same workflow-owned session. Include attribution when resuming the Hook: ```typescript title="app/api/chat/[runId]/message/route.ts" lineNumbers import { chatMessageHook } from "@/workflows/chat-session"; import { getUser } from "@/lib/auth"; export async function POST( request: Request, { params }: { params: Promise<{ runId: string }> } ) { const { runId } = await params; const { message } = await request.json(); const user = await getUser(request); await chatMessageHook.resume(runId, { message: `[${user.name}] ${message}`, }); return Response.json({ success: true }); } ``` To preserve structured attribution across refreshes, persist the corresponding `UIMessage` using the application-history approach described above. ## Related documentation - [Building Durable AI Agents](/docs/ai): Foundation guide for WorkflowAgent - [Message Queueing](/docs/ai/message-queueing): Inject messages between model-call steps - [Resumable Streams](/docs/ai/resumable-streams): Reconnect to durable output - [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK API for durable, resumable agents --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: Patterns for Defining Tools description: Common patterns for defining tools in durable AI agents using Workflow SDK. docs_index: /llms.txt lastUpdated: 2026-10-21 type: guide summary: Define step-level and workflow-level tools for durable AI agents with stream writing and context access. prerequisites: - /docs/ai related: - /docs/ai/streaming-updates-from-tools - /docs/ai/sleep-and-delays - /docs/foundations/workflows-and-steps - /docs/api-reference/workflow/get-writable --- > For an index of all documentation, see [/llms.txt](/llms.txt). This page covers the details for some common patterns when defining tools for AI agents using Workflow SDK. Using WorkflowAgent, we model most tools as steps. These can range from a single function call to an entire multi-day workflow. ## Accessing message context in tools As with regular AI SDK tool definitions, tools in WorkflowAgent receive the tool's input parameters as the first argument and the tool call context as the second. When your tool needs access to the full message history, you can access it via the `messages` property of the tool call context: ```typescript title="tools.ts" lineNumbers import type { ModelMessage } from "ai"; async function getWeather( { city }: { city: string }, { messages, toolCallId }: { messages: ModelMessage[], toolCallId: string }) { // [!code highlight] "use step"; return `Weather in ${city} is sunny`; } ``` ## Writing to streams As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step only to call `getWritable()` for writing custom data parts to the stream. This can be made generic, by creating a helper step function to write arbitrary data to the stream: ```typescript title="tools.ts" lineNumbers import { getWritable } from "workflow"; async function writeToStream(data: any) { "use step"; const writable = getWritable(); const writer = writable.getWriter(); await writer.write(data); writer.releaseLock(); } ``` ## Step-level vs workflow-level tools Tools can be implemented either at the step level or the workflow level, with different capabilities and constraints. | Capability | Step-Level (`"use step"`) | Workflow-Level (`"use workflow"`) | | ------------------------------------- | ------------------------- | --------------------------------- | | `getWritable()` | ✅ | ❌ | | Automatic retries | ✅ | ❌ | | Side-effects (e.g. API calls) allowed | ✅ | ❌ | | `sleep()` | ❌ | ✅ | | `createWebhook()` | ❌ | ✅ | Tools can also combine both by starting out on the workflow level, and calling into steps for I/O operations, like so: ```typescript title="tools.ts" lineNumbers import { sleep } from "workflow"; import type { LanguageModel, ModelMessage } from "ai"; // Step: handles I/O with retries async function performFetch(url: string) { "use step"; const response = await fetch(url); return response.json(); } // Workflow-level: orchestrates steps and can use sleep() async function executeFetchWithDelay({ url }: { url: string }) { const result = await performFetch(url); await sleep("5s"); // Only available at workflow level return result; } ``` --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt) For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md) --- title: Human-in-the-Loop description: Wait for human input or external events before proceeding in your AI agent workflows. docs_index: /llms.txt lastUpdated: 2026-10-21 type: guide summary: Pause agent workflows for human approval using hooks and webhooks, then resume on input. prerequisites: - /docs/ai - /docs/foundations/hooks related: - /docs/ai/chat-session-modeling - /docs/api-reference/workflow/create-webhook - /docs/api-reference/workflow/define-hook - /docs/foundations/workflows-and-steps --- > For an index of all documentation, see [/llms.txt](/llms.txt). A common pre-requisite for running AI agents in production is the ability to wait for human input or external events before proceeding. Workflow SDK's [webhook](/docs/api-reference/workflow/create-webhook) and [hook](/docs/api-reference/workflow/define-hook) primitives enable "human-in-the-loop" patterns where workflows pause until a human takes action, allowing smooth resumption of workflows even after days of inactivity, and provides stability across code deployments. If you need to react to external events programmatically, see the [hooks](/docs/foundations/hooks) documentation for more information. This part of the guide will focus on the human-in-the-loop pattern, which is a subset of the more general hook pattern. ## How it works `defineHook()` creates a typed hook that can be awaited in a workflow. When the tool is called, it creates a hook instance using the tool call ID as the token. The workflow pauses at `await hook` - no compute resources are consumed while waiting for the human to take action. The UI displays the pending tool call with its input data (flight details, price, etc.) and renders approval controls. The user submits their decision through an API endpoint, which resumes the hook with the approval data. The workflow receives the approval data and resumes execution. While this demo uses a client-side button for human approval, you could instead create a webhook and send the approval link over email or Slack to resume the agent. ## Creating a booking approval tool Add a tool that allows the agent to deliberately pause execution until a human approves or rejects a flight booking: ### 1. Define the hook Create a typed hook with a Zod schema for validation: ```typescript title="workflow/hooks/booking-approval.ts" lineNumbers import { defineHook } from "workflow"; import { z } from "zod"; // ... existing imports ... export const bookingApprovalHook = defineHook({ schema: z.object({ approved: z.boolean(), comment: z.string().optional(), }), }); // ... tool definitions ... ``` ### 2. Implement the tool Create a tool that creates a hook instance using the tool call ID as the token. The UI will use this ID to submit the approval. ```typescript title="workflows/chat/steps/tools.ts" lineNumbers import { bookingApprovalHook } from "@/workflows/hooks/booking-approval"; // [!code highlight] // ... async function executeBookingApproval( // [!code highlight] { flightNumber, passengerName, price }: { flightNumber: string; passengerName: string; price: number }, // [!code highlight] { toolCallId }: { toolCallId: string } // [!code highlight] ) { // [!code highlight] // Note: No "use step" here - hooks are workflow-level primitives // [!code highlight] // Use the toolCallId as the hook token so the UI can reference it // [!code highlight] const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight] // Workflow pauses here until the hook is resolved // [!code highlight] const { approved, comment } = await hook; // [!code highlight] if (!approved) { return `Booking rejected: ${comment || "No reason provided"}`; } return `Booking approved for ${passengerName} on flight ${flightNumber}${comment ? ` - Note: ${comment}` : ""}`; } // ... // Adding the tool to the existing tool definitions export const flightBookingTools = { // ... existing tool definitions ... bookingApproval: { description: "Request human approval before booking a flight", inputSchema: z.object({ flightNumber: z.string().describe("Flight number to book"), passengerName: z.string().describe("Name of the passenger"), price: z.number().describe("Total price of the booking"), }), execute: executeBookingApproval, }, }; ``` > Call `defineHook().create()` from within a workflow context, not from within a step. `executeBookingApproval` does not have `"use step"` because it runs in the workflow context where hooks are available. ### 3. Create the API route Create a new API endpoint that the UI will call to submit the approval decision: ```typescript title="app/api/hooks/approval/route.ts" lineNumbers import { bookingApprovalHook } from "@/workflows/hooks/booking-approval"; // [!code highlight] export async function POST(request: Request) { const { toolCallId, approved, comment } = await request.json(); // Schema validation happens automatically // [!code highlight] // Can throw a zod schema validation error, or a await bookingApprovalHook.resume(toolCallId, { // [!code highlight] approved, comment, }); return Response.json({ success: true }); } ``` > This route resumes whichever hook owns `toolCallId`, so anyone who learns the ID can submit a decision. In production, authenticate the request and check that the signed-in user may approve this booking before calling `resume()`. See [Hook and webhook security](/docs/foundations/hooks#security). ### 4. Create the approval component Build a new component that reacts to the tool call data, and allows the user to approve or reject the booking: ```typescript title="components/booking-approval.tsx" lineNumbers "use client"; import { useState } from "react"; interface BookingApprovalProps { toolCallId: string; input?: { flightNumber: string; passengerName: string; price: number; }; output?: string; } export function BookingApproval({ toolCallId, input, output }: BookingApprovalProps) { const [comment, setComment] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); // If we have output, the approval has been processed if (output) { return (

{output}

); } const handleSubmit = async (approved: boolean) => { setIsSubmitting(true); try { await fetch("/api/hooks/approval", { // [!code highlight] method: "POST", // [!code highlight] headers: { "Content-Type": "application/json" }, // [!code highlight] body: JSON.stringify({ toolCallId, approved, comment }), // [!code highlight] }); // [!code highlight] } finally { setIsSubmitting(false); } }; return (

Approve this booking?

{input && (
Flight: {input.flightNumber}
Passenger: {input.passengerName}
Price: ${input.price}
)}