);
diff --git a/docs/components/geistdocs/desktop-menu.tsx b/docs/components/geistdocs/desktop-menu.tsx
index 68a4cc7ff1..b00e9564cf 100644
--- a/docs/components/geistdocs/desktop-menu.tsx
+++ b/docs/components/geistdocs/desktop-menu.tsx
@@ -1,6 +1,7 @@
'use client';
import DynamicLink from 'fumadocs-core/dynamic-link';
+import { useParams, usePathname } from 'next/navigation';
import { IconArrowUpRightSmall } from '@/components/geistcn-fallbacks/geistcn-assets/icons/icon-arrow-up-right-small';
import {
NavigationMenu,
@@ -18,34 +19,50 @@ interface DesktopMenuProps {
export const DesktopMenu = ({ items, className }: DesktopMenuProps) => {
const isMobile = useIsMobile();
+ const pathname = usePathname() ?? '/';
+ const { lang } = useParams<{ lang?: string }>();
+
+ const matchesHref = (href: string) => {
+ const candidates = [href];
+ if (lang) candidates.push(`/${lang}${href}`);
+ return candidates.some(
+ (candidate) =>
+ pathname === candidate || pathname.startsWith(`${candidate}/`)
+ );
+ };
return (
- {items.map((item) => (
-
-
- {item.href.startsWith('http') ? (
-
- {item.label}
-
-
- ) : (
-
- {item.label}
-
- )}
-
-
- ))}
+ {items.map((item) => {
+ const isExternal = item.href.startsWith('http');
+ const isActive = !isExternal && matchesHref(item.href);
+ return (
+
+
+ {isExternal ? (
+
+ {item.label}
+
+
+ ) : (
+
+ {item.label}
+
+ )}
+
+
+ );
+ })}
);
diff --git a/docs/content/docs/cookbook/common-patterns/child-workflows.mdx b/docs/content/docs/cookbook/advanced/child-workflows.mdx
similarity index 99%
rename from docs/content/docs/cookbook/common-patterns/child-workflows.mdx
rename to docs/content/docs/cookbook/advanced/child-workflows.mdx
index 189fe60259..68a64496c4 100644
--- a/docs/content/docs/cookbook/common-patterns/child-workflows.mdx
+++ b/docs/content/docs/cookbook/advanced/child-workflows.mdx
@@ -16,7 +16,7 @@ Child workflows are the right choice when:
- **You want massive fan-out.** Spawning 50 or 500 children is practical because each runs on its own infrastructure.
- **You need per-item observability.** Each child workflow has its own run ID, status, and event log for monitoring.
-For simpler cases where steps share a single event log, use [direct await composition](/docs/foundations/common-patterns#direct-await-flattening) instead.
+For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead.
## Basic pattern: spawn and poll
diff --git a/docs/content/docs/cookbook/advanced/custom-serialization.mdx b/docs/content/docs/cookbook/advanced/custom-serialization.mdx
deleted file mode 100644
index 37e2dabcfb..0000000000
--- a/docs/content/docs/cookbook/advanced/custom-serialization.mdx
+++ /dev/null
@@ -1,168 +0,0 @@
----
-title: Custom Serialization
-description: Make class instances serializable across workflow boundaries using the WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE symbol protocol.
-type: guide
-summary: Implement the WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE symbol protocol on classes so instances survive serialization when passed between workflow and step functions.
----
-
-
-This is an advanced guide. It dives into workflow internals and is not required reading to use workflow.
-
-
-## The Problem
-
-Workflow functions run inside a sandboxed VM. Every value that crosses a function boundary — step arguments, step return values, workflow inputs — must be [serializable](/docs/foundations/serialization). Plain objects, strings, numbers, and many built-in types (`Date`, `Map`, `Set`, `RegExp`, etc.) work automatically, but **class instances** that don't implement the custom class serialization protocol will throw a serialization error.
-
-```typescript lineNumbers
-class StorageClient {
- constructor(private region: string) {}
-
- async upload(key: string, body: Uint8Array) {
- // ... uses this.region internally
- }
-}
-
-export async function processFile(client: StorageClient) {
- "use workflow";
-
- // client fails to serialize — StorageClient doesn't implement custom class serialization
- // The runtime throws a serialization error
- await uploadStep(client, "output.json", data);
-}
-```
-
-Custom class serialization solves this by teaching the runtime how to convert your class instances to plain data and back.
-
-## The WORKFLOW_SERIALIZE / WORKFLOW_DESERIALIZE Protocol
-
-The `@workflow/serde` package exports two symbols that act as a custom class serialization protocol. When the workflow runtime encounters a class instance with these symbols, it knows how to convert it to plain data and back.
-
-{/* @skip-typecheck - @workflow/serde is not mapped in the type-checker */}
-```typescript lineNumbers
-import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
-
-class Point {
- constructor(public x: number, public y: number) {}
-
- distanceTo(other: Point): number {
- return Math.sqrt((this.x - other.x) ** 2 + (this.y - other.y) ** 2);
- }
-
- static [WORKFLOW_SERIALIZE](instance: Point) { // [!code highlight]
- return { x: instance.x, y: instance.y };
- }
-
- static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { // [!code highlight]
- return new Point(data.x, data.y);
- }
-}
-```
-
-Both methods must be **static**. `WORKFLOW_SERIALIZE` receives an instance and returns plain serializable data. `WORKFLOW_DESERIALIZE` receives that same data and reconstructs a new instance.
-
-
-Both serialization methods run inside the workflow VM. They must not use Node.js APIs, non-deterministic operations, or network calls. Keep them focused on extracting and reconstructing data.
-
-
-## Automatic Class Registration
-
-For the runtime to deserialize a class, the class must be registered in a global registry with a stable `classId`. The SWC compiler plugin handles this automatically — when it detects a class with both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` static methods, it generates registration code at build time.
-
-This means you only need to implement the two symbol methods. The compiler assigns a deterministic `classId` based on the file path and class name, and registers it in the global `Symbol.for("workflow-class-registry")` registry.
-
-
-No manual registration is required for classes defined in your workflow files. The SWC plugin detects the serialization symbols and generates the registration automatically at build time.
-
-
-## Full Example: A Workflow-Safe Storage Client
-
-Here's a complete example of a storage client class that survives serialization across workflow boundaries. This pattern is useful when you need an object with methods to be passed as a workflow input or returned from a step.
-
-```typescript lineNumbers
-import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
-
-interface StorageClientOptions {
- region: string;
- bucket: string;
- accessKeyId?: string;
- secretAccessKey?: string;
-}
-
-export class WorkflowStorageClient {
- private readonly region: string;
- private readonly bucket: string;
- private readonly accessKeyId?: string;
- private readonly secretAccessKey?: string;
-
- constructor(options: StorageClientOptions) {
- this.region = options.region;
- this.bucket = options.bucket;
- this.accessKeyId = options.accessKeyId;
- this.secretAccessKey = options.secretAccessKey;
- }
-
- async upload(key: string, body: Uint8Array) {
- "use step";
- const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
- const client = new S3Client({
- region: this.region,
- credentials: this.accessKeyId
- ? { accessKeyId: this.accessKeyId, secretAccessKey: this.secretAccessKey! }
- : undefined,
- });
- await client.send(
- new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body })
- );
- }
-
- async getSignedUrl(key: string): Promise {
- "use step";
- const { S3Client, GetObjectCommand } = await import("@aws-sdk/client-s3");
- const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");
- const client = new S3Client({ region: this.region });
- return getSignedUrl(client, new GetObjectCommand({ Bucket: this.bucket, Key: key }));
- }
-
- // --- Serialization protocol ---
-
- static [WORKFLOW_SERIALIZE](instance: WorkflowStorageClient): StorageClientOptions { // [!code highlight]
- return {
- region: instance.region,
- bucket: instance.bucket,
- accessKeyId: instance.accessKeyId,
- secretAccessKey: instance.secretAccessKey,
- };
- }
-
- static [WORKFLOW_DESERIALIZE]( // [!code highlight]
- data: StorageClientOptions
- ): WorkflowStorageClient {
- return new WorkflowStorageClient(data);
- }
-}
-```
-
-Now this client can be passed into a workflow and used directly:
-
-```typescript lineNumbers
-import { WorkflowStorageClient } from "./storage-client";
-
-export async function processUpload(
- client: WorkflowStorageClient,
- data: Uint8Array
-) {
- "use workflow";
-
- // client is a real WorkflowStorageClient with working methods
- await client.upload("output/result.json", data); // [!code highlight]
- const url = await client.getSignedUrl("output/result.json"); // [!code highlight]
- return { url };
-}
-```
-
-## Key APIs
-
-- [`WORKFLOW_SERIALIZE`](/docs/api-reference/workflow-serde/workflow-serialize) — symbol for the static serialization method
-- [`WORKFLOW_DESERIALIZE`](/docs/api-reference/workflow-serde/workflow-deserialize) — symbol for the static deserialization method
-- [`"use step"`](/docs/api-reference/workflow/use-step) — marks a function for extraction and serialization
-- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
diff --git a/docs/content/docs/cookbook/common-patterns/distributed-abort-controller.mdx b/docs/content/docs/cookbook/advanced/distributed-abort-controller.mdx
similarity index 100%
rename from docs/content/docs/cookbook/common-patterns/distributed-abort-controller.mdx
rename to docs/content/docs/cookbook/advanced/distributed-abort-controller.mdx
diff --git a/docs/content/docs/cookbook/advanced/durable-objects.mdx b/docs/content/docs/cookbook/advanced/durable-objects.mdx
deleted file mode 100644
index b633c5e635..0000000000
--- a/docs/content/docs/cookbook/advanced/durable-objects.mdx
+++ /dev/null
@@ -1,148 +0,0 @@
----
-title: Durable Objects
-description: Model long-lived stateful entities as workflows that persist state across requests.
-type: guide
-summary: Build a durable counter or session object whose state survives restarts by using a workflow's event log as the persistence layer.
----
-
-
-This is an advanced guide. It dives into workflow internals and is not required reading to use workflow.
-
-
-## The Idea
-
-A workflow's event log already records every step result and replays them to reconstruct state. This is the same property that makes an "object" durable — its fields survive cold starts, crashes, and redeployments. Instead of using a workflow to model a *process*, you can use one to model an *entity* with methods.
-
-Each "method call" is a hook that the object's workflow loop awaits. External callers resume the hook with a payload describing the operation. The workflow applies the operation, updates its internal state, and waits for the next call.
-
-## Pattern: Durable Counter
-
-A counter that persists its value without a database. Each increment/decrement is recorded in the event log.
-
-```typescript lineNumbers
-import { defineHook, getWorkflowMetadata } from "workflow";
-import { z } from "zod";
-
-const counterAction = defineHook({ // [!code highlight]
- schema: z.object({
- type: z.enum(["increment", "decrement", "get"]),
- amount: z.number().default(1),
- }),
-});
-
-export async function durableCounter() {
- "use workflow";
-
- let count = 0;
- const { workflowRunId } = getWorkflowMetadata();
-
- while (true) {
- const hook = counterAction.create({ token: `counter:${workflowRunId}` });
- const action = await hook; // [!code highlight]
-
- switch (action.type) {
- case "increment":
- count += action.amount;
- await recordState(count);
- break;
- case "decrement":
- count -= action.amount;
- await recordState(count);
- break;
- case "get":
- await emitValue(count);
- break;
- }
- }
-}
-
-async function recordState(count: number) {
- "use step";
- // Step records the state transition in the event log.
- // On replay, the step result restores `count` without re-executing.
- return count;
-}
-
-async function emitValue(count: number) {
- "use step";
- return { count };
-}
-```
-
-### Calling the Object
-
-From an API route, resume the hook to "invoke a method" on the durable object:
-
-```typescript lineNumbers
-import { resumeHook } from "workflow/api";
-
-export async function POST(request: Request) {
- const { runId, type, amount } = await request.json();
- await resumeHook(`counter:${runId}`, { type, amount }); // [!code highlight]
- return Response.json({ ok: true });
-}
-```
-
-## Pattern: Durable Session
-
-A chat session where conversation history is the durable state. Each user message is a hook event; the workflow accumulates messages and generates responses.
-
-```typescript lineNumbers
-import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
-import { DurableAgent } from "@workflow/ai/agent";
-import { anthropic } from "@workflow/ai/anthropic";
-import { z } from "zod";
-import type { UIMessageChunk, ModelMessage } from "ai";
-
-const messageHook = defineHook({ // [!code highlight]
- schema: z.object({
- role: z.literal("user"),
- content: z.string(),
- }),
-});
-
-export async function durableSession() {
- "use workflow";
-
- const writable = getWritable();
- const { workflowRunId: runId } = getWorkflowMetadata();
- const messages: ModelMessage[] = [];
-
- const agent = new DurableAgent({
- model: anthropic("claude-sonnet-4-20250514"),
- instructions: "You are a helpful assistant.",
- });
-
- while (true) {
- const hook = messageHook.create({ token: `session:${runId}` });
- const userMessage = await hook; // [!code highlight]
-
- messages.push({
- role: userMessage.role,
- content: userMessage.content,
- });
-
- await agent.stream({ messages, writable });
- }
-}
-```
-
-## When to Use This
-
-- **Entity-per-workflow**: Each user, document, or device gets its own workflow run. The run ID is the entity ID.
-- **No external database needed**: State lives in the event log. Reads replay from the log; writes append to it.
-- **Automatic consistency**: Only one execution runs at a time per workflow run, so there are no race conditions on the entity's state.
-
-## Trade-offs
-
-- **Read latency**: Accessing current state requires replaying the event log (or caching the last known state in a step result).
-- **Not a replacement for databases**: If you need to query across entities (e.g., "all counters above 100"), you still need a database. Durable objects are for single-entity state.
-- **Log growth**: Long-lived objects accumulate large event logs. Consider periodic "snapshot" steps that checkpoint the full state.
-
-## Key APIs
-
-- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
-- [`"use step"`](/docs/api-reference/workflow/use-step) — marks functions for durable execution
-- [`defineHook`](/docs/api-reference/workflow/define-hook) — type-safe hook for receiving external method calls
-- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
-- [`resumeHook`](/docs/api-reference/workflow-api/resume-hook) — invoke a method on the durable object from an API route
diff --git a/docs/content/docs/cookbook/advanced/isomorphic-packages.mdx b/docs/content/docs/cookbook/advanced/isomorphic-packages.mdx
deleted file mode 100644
index eb30cdd9d6..0000000000
--- a/docs/content/docs/cookbook/advanced/isomorphic-packages.mdx
+++ /dev/null
@@ -1,145 +0,0 @@
----
-title: Isomorphic Packages
-description: Publish reusable workflow packages that work both inside and outside the workflow runtime.
-type: guide
-summary: Use try/catch around getWorkflowMetadata, dynamic imports, and optional peer dependencies to build libraries that run in workflows and in plain Node.js.
----
-
-
-This is an advanced guide. It dives into workflow internals and is not required reading to use workflow.
-
-
-## The Challenge
-
-If you're a library author publishing a package that integrates with workflow, your code needs to handle two environments:
-
-1. **Inside a workflow run** — `getWorkflowMetadata()` works, `"use step"` directives are transformed, and the full workflow runtime is available.
-2. **Outside a workflow** — your package is imported in a regular Node.js process, a test suite, or a project that doesn't use workflow at all.
-
-A hard dependency on `workflow` will crash at import time for users who don't have it installed.
-
-## Pattern 1: Feature-Detect with `getWorkflowMetadata`
-
-Use a try/catch to detect whether you're running inside a workflow. This lets you add durable behavior when available and fall back to standard execution otherwise.
-
-```typescript lineNumbers
-import { getWorkflowMetadata } from "workflow";
-
-export async function processPayment(amount: number, currency: string) {
- "use workflow";
-
- let runId: string | undefined;
- try {
- const metadata = getWorkflowMetadata(); // [!code highlight]
- runId = metadata.workflowRunId;
- } catch {
- // Not running inside a workflow — proceed without durability
- runId = undefined;
- }
-
- if (runId) {
- // Inside a workflow: use the run ID as an idempotency key
- return await chargeWithIdempotency(amount, currency, runId); // [!code highlight]
- } else {
- // Outside a workflow: standard charge
- return await chargeStandard(amount, currency);
- }
-}
-
-async function chargeWithIdempotency(amount: number, currency: string, idempotencyKey: string) {
- "use step";
- // Stripe charge with idempotency key from workflow run ID
- return { charged: true, amount, currency, idempotencyKey };
-}
-
-async function chargeStandard(amount: number, currency: string) {
- "use step";
- return { charged: true, amount, currency };
-}
-```
-
-## Pattern 2: Dynamic Imports
-
-Avoid importing `workflow` at the top level. Use dynamic `import()` so the module is only loaded when actually needed.
-
-```typescript lineNumbers
-export async function createDurableTask(name: string, payload: unknown) {
- "use workflow";
-
- let sleep: ((duration: string) => Promise) | undefined;
-
- try {
- const wf = await import("workflow"); // [!code highlight]
- sleep = wf.sleep;
- } catch {
- // workflow not installed — use setTimeout fallback
- sleep = undefined;
- }
-
- await executeTask(name, payload);
-
- if (sleep) {
- // Inside workflow: durable sleep that survives restarts
- await sleep("5m"); // [!code highlight]
- } else {
- // Outside workflow: plain timer (not durable)
- await new Promise((resolve) => setTimeout(resolve, 5 * 60 * 1000));
- }
-
- await sendNotification(name);
-}
-
-async function executeTask(name: string, payload: unknown) {
- "use step";
- return { executed: true, name, payload };
-}
-
-async function sendNotification(name: string) {
- "use step";
- return { notified: true, name };
-}
-```
-
-## Pattern 3: Optional Peer Dependencies
-
-In your `package.json`, declare `workflow` as an optional peer dependency. This signals to package managers that your library *can* use workflow but doesn't require it.
-
-```json
-{
- "name": "@acme/payments",
- "peerDependencies": {
- "workflow": ">=1.0.0"
- },
- "peerDependenciesMeta": {
- "workflow": {
- "optional": true
- }
- }
-}
-```
-
-Then guard all workflow imports with dynamic `import()` and try/catch as shown above.
-
-## Real-World Examples
-
-### Mux AI
-
-The Mux team published a reusable workflow package for video processing. Their library detects the workflow runtime and falls back to standard async processing when workflow isn't available.
-
-### World ID
-
-World ID's identity verification library uses `getWorkflowMetadata()` to attach run IDs to their human-in-the-loop verification hooks, but the same library works in non-workflow environments for simple verification flows.
-
-## Guidelines for Library Authors
-
-1. **Never hard-import `workflow` at the top level** if your package should work without it.
-2. **Use `getWorkflowMetadata()` in a try/catch** as the canonical runtime detection pattern.
-3. **Mark `workflow` as an optional peer dependency** in `package.json`.
-4. **Test both paths**: run your test suite with and without the workflow runtime to catch import errors.
-5. **Document the dual behavior**: make it clear in your README which features require workflow and which work standalone.
-
-## Key APIs
-
-- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
-- [`"use step"`](/docs/api-reference/workflow/use-step) — marks functions for durable execution
-- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — runtime detection and run ID access
diff --git a/docs/content/docs/cookbook/advanced/meta.json b/docs/content/docs/cookbook/advanced/meta.json
index 638f041d9f..1b8ea39d44 100644
--- a/docs/content/docs/cookbook/advanced/meta.json
+++ b/docs/content/docs/cookbook/advanced/meta.json
@@ -1,10 +1,9 @@
{
"title": "Advanced",
"pages": [
+ "child-workflows",
+ "distributed-abort-controller",
"serializable-steps",
- "durable-objects",
- "isomorphic-packages",
- "custom-serialization",
"publishing-libraries"
]
}
diff --git a/docs/content/docs/cookbook/advanced/publishing-libraries.mdx b/docs/content/docs/cookbook/advanced/publishing-libraries.mdx
index 8460d68eee..e38e8de1c4 100644
--- a/docs/content/docs/cookbook/advanced/publishing-libraries.mdx
+++ b/docs/content/docs/cookbook/advanced/publishing-libraries.mdx
@@ -5,6 +5,8 @@ type: guide
summary: Learn how to build, export, and test npm packages that ship workflow and step functions — including package.json exports, re-exporting for stable workflow IDs, keeping step I/O clean, and integration testing.
---
+import { File, Folder, Files } from "fumadocs-ui/components/files";
+
This is an advanced guide for library authors who want to publish reusable workflow functions as npm packages. It assumes familiarity with `"use workflow"`, `"use step"`, and the workflow execution model.
@@ -13,23 +15,37 @@ This is an advanced guide for library authors who want to publish reusable workf
A workflow library follows a standard TypeScript package layout with a dedicated `workflows/` directory. Each workflow file exports one or more workflow functions that consumers can import and pass to `start()`.
-```
-my-media-lib/
-├── src/
-│ ├── index.ts # Package entry point
-│ ├── types.ts # Shared types
-│ ├── workflows/
-│ │ ├── index.ts # Re-exports all workflows
-│ │ ├── transcode.ts # Workflow: transcode a video
-│ │ └── generate-thumbnails.ts
-│ └── lib/
-│ └── api-client.ts # Internal helpers (NOT steps)
-├── test-server/
-│ └── workflows.ts # Re-export for integration tests
-├── tsup.config.ts
-├── package.json
-└── tsconfig.json
-```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Key files:
+
+- **`src/index.ts`** — Package entry point. Exports the public API.
+- **`src/types.ts`** — Shared TypeScript types.
+- **`src/workflows/index.ts`** — Re-exports every workflow so consumers can pull them in under one specifier (see [Entry Points and Exports](#entry-points-and-exports)).
+- **`src/workflows/*.ts`** — One file per workflow function (e.g. `transcode.ts`, `generate-thumbnails.ts`).
+- **`src/lib/`** — Internal helpers. Plain async code, *not* marked with `"use workflow"` or `"use step"`.
+- **`test-server/workflows.ts`** — Re-export file used by integration tests (see [Testing Workflow Libraries](#testing-workflow-libraries)).
### Entry Points and Exports
@@ -126,7 +142,7 @@ When you publish a workflow library, every step function's inputs and outputs ar
### 1. Everything Must Be Serializable
-Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON — including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/cookbook/advanced/custom-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return:
+Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON — including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/foundations/serialization#custom-class-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return:
- Functions or closures
- `WeakRef`, `WeakMap`, or `WeakSet`
@@ -226,7 +242,18 @@ pnpm vitest run --config vitest.workflowsdk.config.ts
## Working With and Without Workflow Installed
-If your library should work both as a standalone package and inside Workflow SDK, declare `workflow` as an optional peer dependency:
+Some libraries want to be useful to consumers who *aren't* using Workflow SDK at all — the library picks up durable behavior when a workflow runtime is present and falls back to plain async execution otherwise.
+
+
+Two rules for isomorphic packages:
+
+1. **Any runtime reference to the `workflow` package must be loaded via dynamic `import("workflow")` inside a try/catch.** A static top-level import makes the module fail to load for consumers who haven't installed workflow.
+2. **The `"use workflow"` and `"use step"` directives are safe to keep in your library source.** When a consumer compiles your code with the Workflow SDK toolchain (via the [re-export pattern](#re-exporting-for-workflow-id-stability) above), the SWC plugin transforms them into durable-execution glue. When they're not compiled — plain Node, plain tests, a consumer without the runtime — they are just string expression statements and run as no-ops.
+
+
+### Optional peer dependency
+
+Declare `workflow` as an **optional** peer so consumers without the runtime aren't forced to install it:
```json
{
@@ -241,22 +268,52 @@ If your library should work both as a standalone package and inside Workflow SDK
}
```
-Use dynamic imports and runtime detection so your library gracefully degrades when workflow is not installed:
+### Runtime detection
+
+Wrap a dynamic `import("workflow")` in try/catch. If either the module isn't installed *or* `getWorkflowMetadata()` throws (call site isn't inside a workflow run), fall through to the standalone path.
```typescript lineNumbers
-async function isWorkflowRuntime(): Promise {
+async function getWorkflowRunId(): Promise { // [!code highlight]
try {
const wf = await import("workflow");
- if (typeof wf.getWorkflowMetadata !== "function") return false;
- wf.getWorkflowMetadata(); // [!code highlight]
- return true;
+ const { workflowRunId } = wf.getWorkflowMetadata();
+ return workflowRunId;
} catch {
- return false;
+ return null;
}
}
```
-See [Isomorphic Packages](/docs/cookbook/advanced/isomorphic-packages) for the full pattern including feature detection, dynamic imports, and dual-path execution.
+### A concrete use case: replay-safe idempotency keys
+
+A payments utility that uses the workflow run ID as a Stripe idempotency key when available, and a fresh UUID otherwise:
+
+{/* @skip-typecheck - depends on getWorkflowRunId defined in the previous block */}
+```typescript lineNumbers
+export async function processPayment(amount: number, currency: string) {
+ const runId = await getWorkflowRunId();
+ const idempotencyKey = runId ?? crypto.randomUUID(); // [!code highlight]
+
+ const res = await fetch("https://api.stripe.com/v1/charges", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
+ "Idempotency-Key": idempotencyKey, // [!code highlight]
+ },
+ body: new URLSearchParams({ amount: String(amount), currency }),
+ });
+ return res.json();
+}
+```
+
+When called from inside a workflow run, the step wrapping this utility gets a stable idempotency key across replays — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated.
+
+### In production
+
+Packages in the wild built on Workflow SDK:
+
+- **[`@mux/ai`](https://github.com/muxinc/ai)** — Reusable video AI workflows (summaries, chapters, content moderation, translation, embeddings) exported with `"use workflow"` / `"use step"` directives. In a standard Node environment the directives are no-ops and the SDK runs as a plain async library; in a Workflow SDK environment the consumer's compiler transforms them into durable, resumable steps with automatic retries and observability. Written up in detail in [*How Mux shipped durable video workflows with their @mux/ai SDK*](https://vercel.com/blog/how-mux-shipped-durable-video-workflows-with-their-mux-ai-sdk) on the Vercel blog.
+- **World ID** — Human-in-the-loop "proof of human" primitive for agent workflows. Developers drop a World ID step into any workflow to require a zero-knowledge cryptographic proof that a real, unique human authorized a specific action (deploy approvals, large payments, sensitive data access, etc.). Because it runs as a workflow step, every verification is durable, replay-safe, and viewable inside the run's execution timeline — giving you a provable audit record of which human approved what. Available on npm and announced in [*World ID for agents: Browserbase, Exa, Okta, and Vercel*](https://world.org/blog/announcements/browserbase-exa-okta-world-id-for-agentic-web) on the World blog.
## Checklist
diff --git a/docs/content/docs/cookbook/advanced/serializable-steps.mdx b/docs/content/docs/cookbook/advanced/serializable-steps.mdx
index 71b6201c05..8e1da86673 100644
--- a/docs/content/docs/cookbook/advanced/serializable-steps.mdx
+++ b/docs/content/docs/cookbook/advanced/serializable-steps.mdx
@@ -1,17 +1,28 @@
---
title: Serializable Steps
-description: Wrap non-serializable objects (like AI model providers) inside step functions so they can cross the workflow boundary.
+description: Wrap non-serializable third-party objects (like AI model providers) inside step factory functions so they can cross the workflow boundary.
type: guide
-summary: Return a callback from a step to defer provider initialization, making non-serializable AI SDK models work inside durable workflows.
+summary: Return a callback from a step to defer construction of a non-owned class (AI SDK models, cloud SDK clients) until execution time, making them usable inside durable workflows.
+related:
+ - /docs/foundations/serialization
+ - /docs/foundations/serialization#custom-class-serialization
+ - /docs/api-reference/workflow/use-step
---
This is an advanced guide. It dives into workflow internals and is not required reading to use workflow.
+## When to use this pattern
+
+Workflow functions run inside a sandboxed VM where every value that crosses a function boundary must be serializable. There are two ways to get a non-serializable object across that boundary, depending on whether you own the class:
+
+- **You own the class** — implement the [`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` protocol](/docs/foundations/serialization#custom-class-serialization). The instance becomes a first-class serializable value: you can pass it as a workflow input, return it from a step, and call `"use step"` instance methods on it directly. This is the right tool when the class is yours to modify.
+- **You don't own the class** — you can't add methods to `openai("gpt-4o")` from `@ai-sdk/openai` or `new S3Client({...})` from `@aws-sdk/client-s3`. Instead, wrap construction in a `"use step"` factory function and pass the factory across the boundary. That's what this page covers.
+
## The Problem
-Workflow functions run inside a sandboxed VM where every value that crosses a function boundary must be serializable (JSON-safe). AI SDK model providers — `openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc. — return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error.
+AI SDK model providers — `openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc. — return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error, and you can't bolt `WORKFLOW_SERIALIZE` onto a third-party class.
```typescript lineNumbers
import { openai } from "@ai-sdk/openai";
@@ -133,3 +144,4 @@ async function uploadFile(
- [`"use step"`](/docs/api-reference/workflow/use-step) — marks a function for extraction and serialization
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — accepts a model factory for durable AI agent streaming
+- [Custom class serialization](/docs/foundations/serialization#custom-class-serialization) — the companion pattern for classes you own (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`)
diff --git a/docs/content/docs/cookbook/agent-patterns/agent-cancellation.mdx b/docs/content/docs/cookbook/agent-patterns/agent-cancellation.mdx
new file mode 100644
index 0000000000..b793ab731a
--- /dev/null
+++ b/docs/content/docs/cookbook/agent-patterns/agent-cancellation.mdx
@@ -0,0 +1,205 @@
+---
+title: Agent Cancellation
+description: Cancel a running agent from the outside — either immediately via run.cancel() or gracefully via a stop signal hook.
+type: guide
+summary: Two patterns for cancelling a running agent — Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook + Promise.race for a clean exit with cleanup and final stream notification.
+---
+
+Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback. Two patterns are available depending on whether you need the agent to exit cleanly or just need the run to stop: **Hard Cancellation** via `getRun(runId).cancel()` for immediate forced termination, or **Stop Signal** via a hook + `Promise.race` for a graceful exit that runs cleanup and notifies streaming clients before returning.
+
+## When to use this
+
+* **Chat stop buttons** — let users cancel a long-running agent from the browser
+* **Admin cancellation** — stop an agent from a different process or API
+* **Timeout fallback** — combine with `sleep()` to auto-stop after a deadline
+
+## Choosing an approach
+
+Pick the option that matches what your endpoint needs to deliver to the caller:
+
+* **Hard Cancellation** — terminates the run immediately with no opportunity for cleanup or client notification. A single line of code, but the workflow throws `WorkflowRunCancelledError` and any streaming clients see an abrupt connection close.
+* **Stop Signal** — the workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a real result.
+
+The trade-offs at a glance:
+
+| | Hard Cancellation | Stop Signal |
+| --- | --- | --- |
+| Mechanism | `getRun(runId).cancel()` | Hook + `Promise.race` |
+| Speed to terminate | Immediate | At the next `await` boundary in the workflow |
+| Runs `finally` / cleanup | No | Yes |
+| Final stream notification | No (abrupt close) | Yes (`data-stopped` part) |
+| `run.returnValue` | Throws `WorkflowRunCancelledError` | Returns the workflow's result |
+| Code complexity | One line | Hook + race + signal step |
+| Best for | Stuck or unresponsive runs, forced termination | User-facing stop, admin cancel, timeouts |
+
+## Hard Cancellation
+
+Call `.cancel()` on a run to terminate it immediately:
+
+```typescript lineNumbers
+import { getRun } from "workflow/api";
+
+export async function POST(
+ _request: Request,
+ { params }: { params: Promise<{ runId: string }> }
+) {
+ const { runId } = await params;
+ await getRun(runId).cancel(); // [!code highlight]
+ return Response.json({ success: true });
+}
+```
+
+This is an abrupt termination — the run is stopped mid-step with no opportunity to exit cleanly:
+
+* **No cleanup runs** — `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped
+* **No final notification to the client** — the writable closes abruptly, so a streaming UI just sees the connection drop with no `data-stopped` part to render a clean ending
+* **`run.returnValue` throws** — anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload
+* **Underlying step keeps running** — same caveat as the Stop Signal pattern below: the model stream or HTTP call inside the current step continues to completion in the background
+
+Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else — chat stop buttons, admin "stop" actions, timeout fallbacks — you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result.
+
+## Stop Signal
+
+
+**Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes — tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
+
+
+### Example
+
+```typescript lineNumbers
+import { DurableAgent } from "@workflow/ai/agent";
+import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
+import { z } from "zod";
+import type { ModelMessage, UIMessageChunk } from "ai";
+
+export const stopHook = defineHook({
+ schema: z.object({ reason: z.string().optional() }),
+});
+
+async function searchWeb({ query }: { query: string }) {
+ "use step";
+ await new Promise((r) => setTimeout(r, 1500));
+ return { results: [{ title: `${query} - Wikipedia`, snippet: `Overview of ${query}...` }] };
+}
+
+async function analyzeData({ topic }: { topic: string }) {
+ "use step";
+ await new Promise((r) => setTimeout(r, 1200));
+ return { summary: `Analysis of ${topic}: significant developments found.`, confidence: 0.85 };
+}
+
+async function emitStopSignal(details: { reason?: string }) { // [!code highlight]
+ "use step";
+ const writer = getWritable().getWriter();
+ try {
+ await writer.write({ type: "data-stopped", id: "stop-signal", data: details } as UIMessageChunk);
+ } finally {
+ writer.releaseLock();
+ }
+}
+
+export async function stoppableAgent(messages: ModelMessage[]) {
+ "use workflow";
+
+ const { workflowRunId } = getWorkflowMetadata();
+ const hook = stopHook.create({ token: `stop:${workflowRunId}` }); // [!code highlight]
+
+ const agent = new DurableAgent({
+ model: "anthropic/claude-haiku-4.5",
+ instructions: "You are a research assistant. Search and analyze data as needed.",
+ tools: {
+ searchWeb: {
+ description: "Search the web for information",
+ inputSchema: z.object({ query: z.string() }),
+ execute: searchWeb,
+ },
+ analyzeData: {
+ description: "Analyze a piece of data",
+ inputSchema: z.object({ topic: z.string() }),
+ execute: analyzeData,
+ },
+ },
+ });
+
+ const result = await Promise.race([ // [!code highlight]
+ agent
+ .stream({ messages, writable: getWritable(), maxSteps: 15 })
+ .then((r) => ({ type: "complete" as const, messages: r.messages })),
+ hook.then(({ reason }) => ({ type: "stopped" as const, reason })), // [!code highlight]
+ ]);
+
+ if (result.type === "stopped") {
+ await emitStopSignal({ reason: result.reason }); // [!code highlight]
+ }
+
+ return result;
+}
+```
+
+### API Route to Trigger Stop
+
+```typescript lineNumbers
+import { stopHook } from "@/workflows/stoppable-agent";
+
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ runId: string }> }
+) {
+ const { runId } = await params;
+ const { reason } = await request.json();
+
+ await stopHook.resume(`stop:${runId}`, { // [!code highlight]
+ reason: reason || "User requested stop",
+ });
+
+ return Response.json({ success: true });
+}
+```
+
+### Client Stop Button
+
+```tsx lineNumbers
+"use client";
+
+export function StopButton({ runId }: { runId: string }) {
+ const handleStop = async () => {
+ await fetch(`/api/chat/${runId}/stop`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reason: "User clicked stop" }),
+ });
+ };
+
+ return (
+
+ );
+}
+```
+
+## How it works
+
+1. A hook is created with token `stop:${workflowRunId}` when the workflow starts
+2. `Promise.race` runs the agent stream and the stop hook concurrently
+3. When the stop API resumes the hook, the race resolves immediately — the workflow exits
+4. Before returning, `emitStopSignal` writes a `data-stopped` part to the stream so the client knows the agent was stopped (not just disconnected)
+5. The client detects `data-stopped` and updates the UI accordingly
+
+This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — race a long-running operation against a hook signal.
+
+## Adapting this
+
+* **Add a timeout** — race a third `sleep()` promise to auto-stop after a deadline
+* **Audit logging** — include a `reason` field in the stop schema to record who stopped and why
+* **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID
+* **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop
+* **Hard Cancellation as a fallback** — wire your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume errors with `not found` / `expired` (for example, the hook was already consumed). This guarantees the run is terminated even when the Stop Signal path is unavailable.
+
+## Key APIs
+
+* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal
+* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
+* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream a stop notification to the client
+* [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — the agent that gets raced against the stop hook
+* [`getRun()`](/docs/api-reference/workflow-api/get-run) — entry point for Hard Cancellation: `getRun(runId).cancel()`
diff --git a/docs/content/docs/cookbook/agent-patterns/durable-agent.mdx b/docs/content/docs/cookbook/agent-patterns/durable-agent.mdx
index 382c1e54e9..e4e4783aa5 100644
--- a/docs/content/docs/cookbook/agent-patterns/durable-agent.mdx
+++ b/docs/content/docs/cookbook/agent-patterns/durable-agent.mdx
@@ -7,116 +7,61 @@ summary: Convert an AI SDK Agent into a DurableAgent backed by a workflow, with
Use this pattern to make any AI SDK agent durable. The agent becomes a workflow, tools become steps, and the framework handles retries, streaming, and state persistence automatically.
-## Pattern
-
-Replace `Agent` with `DurableAgent`, wrap the function in `"use workflow"`, mark each tool with `"use step"`, and stream output through `getWritable()`.
-
-### Simplified
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { getWritable } from "workflow";
-import { z } from "zod";
-import type { ModelMessage, UIMessageChunk } from "ai";
+## When to use this
-declare function searchFlights(args: { from: string; to: string; date: string }): Promise<{ flights: { id: string; price: number }[] }>; // @setup
-declare function bookFlight(args: { flightId: string; passenger: string }): Promise<{ confirmationId: string }>; // @setup
-
-export async function flightAgent(messages: ModelMessage[]) {
- "use workflow";
+- Any AI agent with tool calls that should survive crashes and restarts
+- Agents where tool calls hit external APIs that need automatic retries
+- Long-running agent sessions where losing progress is unacceptable
+- Agents that need per-step observability in the workflow event log
- const agent = new DurableAgent({ // [!code highlight]
- model: "anthropic/claude-haiku-4.5",
- instructions: "You are a helpful flight booking assistant.",
- tools: {
- searchFlights: {
- description: "Search for available flights",
- inputSchema: z.object({
- from: z.string(),
- to: z.string(),
- date: z.string(),
- }),
- execute: searchFlights,
- },
- bookFlight: {
- description: "Book a specific flight",
- inputSchema: z.object({
- flightId: z.string(),
- passenger: z.string(),
- }),
- execute: bookFlight,
- },
- },
- });
+## Pattern
- await agent.stream({ // [!code highlight]
- messages,
- writable: getWritable(),
- });
-}
-```
+Replace `Agent` with `DurableAgent`, wrap the function in `"use workflow"`, mark each tool with `"use step"`, and stream output through `getWritable()`.
-### Full Implementation
+### Workflow
-```typescript lineNumbers
+```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { getWritable } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";
-// Step: Search flights with full Node.js access and automatic retries
-async function searchFlights({
- from,
- to,
- date,
-}: {
+async function searchFlights({ from, to, date }: {
from: string;
to: string;
date: string;
}) {
- "use step";
-
- const response = await fetch(
+ "use step"; // [!code highlight]
+ const res = await fetch(
`https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
);
- if (!response.ok) throw new Error(`Search failed: ${response.status}`);
- return response.json();
+ if (!res.ok) throw new Error(`Search failed: ${res.status}`);
+ return res.json();
}
-// Step: Book a flight — retries on transient failures
-async function bookFlight({
- flightId,
- passenger,
-}: {
+async function bookFlight({ flightId, passenger }: {
flightId: string;
passenger: string;
}) {
- "use step";
-
- const response = await fetch("https://api.example.com/bookings", {
+ "use step"; // [!code highlight]
+ const res = await fetch("https://api.example.com/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flightId, passenger }),
});
- if (!response.ok) throw new Error(`Booking failed: ${response.status}`);
- return response.json();
+ if (!res.ok) throw new Error(`Booking failed: ${res.status}`);
+ return res.json();
}
-// Step: Check flight status
-async function checkStatus({ flightId }: { flightId: string }) {
- "use step";
-
- const response = await fetch(
- `https://api.example.com/flights/${flightId}/status`
- );
- return response.json();
+async function checkWeather({ city }: { city: string }) {
+ "use step"; // [!code highlight]
+ const res = await fetch(`https://api.weather.com/forecast?city=${city}`);
+ return res.json();
}
export async function flightAgent(messages: ModelMessage[]) {
"use workflow";
- const writable = getWritable(); // [!code highlight]
-
const agent = new DurableAgent({ // [!code highlight]
model: "anthropic/claude-haiku-4.5",
instructions: "You are a helpful flight booking assistant.",
@@ -138,19 +83,19 @@ export async function flightAgent(messages: ModelMessage[]) {
}),
execute: bookFlight,
},
- checkStatus: {
- description: "Check the current status of a flight",
+ checkWeather: {
+ description: "Check the weather forecast for a city",
inputSchema: z.object({
- flightId: z.string().describe("Flight ID to check"),
+ city: z.string().describe("City name"),
}),
- execute: checkStatus,
+ execute: checkWeather,
},
},
});
const result = await agent.stream({ // [!code highlight]
messages,
- writable,
+ writable: getWritable(), // [!code highlight]
maxSteps: 10,
});
@@ -158,22 +103,21 @@ export async function flightAgent(messages: ModelMessage[]) {
}
```
-### API Route
+### API route
-```typescript lineNumbers
-import { createUIMessageStreamResponse } from "ai";
-import { start } from "workflow/api";
-import { flightAgent } from "@/workflows/flight-agent";
+```typescript
import type { UIMessage } from "ai";
-import { convertToModelMessages } from "ai";
+import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
+import { start } from "workflow/api";
+import { flightAgent } from "@/app/workflows/flight-agent";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
- const modelMessages = await convertToModelMessages(messages);
+ const modelMessages = await convertToModelMessages(messages); // [!code highlight]
const run = await start(flightAgent, [modelMessages]); // [!code highlight]
- return createUIMessageStreamResponse({
+ return createUIMessageStreamResponse({ // [!code highlight]
stream: run.readable,
headers: {
"x-workflow-run-id": run.runId,
@@ -182,6 +126,21 @@ export async function POST(req: Request) {
}
```
+## How it works
+
+1. **DurableAgent wraps Agent** — same API as AI SDK's `Agent`, but backed by a workflow. If the process crashes, the agent resumes from the last completed step on replay.
+2. **Tools as steps** — each tool's `execute` function uses `"use step"`, giving it automatic retries, full Node.js access, and an entry in the workflow event log.
+3. **Streaming** — `getWritable()` streams the agent's output (text chunks, tool calls, tool results) to the client in real time via `createUIMessageStreamResponse`.
+4. **maxSteps** — limits the total number of LLM calls the agent can make, preventing runaway tool loops.
+
+## Adapting to your use case
+
+- **Change the model** — replace `"anthropic/claude-haiku-4.5"` with any AI Gateway model string (e.g. `"openai/gpt-4o"`, `"anthropic/claude-sonnet-4-5"`).
+- **Add tools** — define a new `"use step"` function with a Zod schema. Each tool automatically gets retries and persistence.
+- **Workflow-level tools** — if a tool needs workflow primitives like `sleep()` or `createHook()`, omit `"use step"` so it runs in the workflow context instead.
+- **Multi-turn** — pass `result.messages` plus new user messages to subsequent `agent.stream()` calls for multi-turn conversations.
+- **Client integration** — use `useChat()` from `@ai-sdk/react` with `WorkflowChatTransport` from `@workflow/ai` for a full chat UI with reconnection support.
+
## Key APIs
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
diff --git a/docs/content/docs/cookbook/agent-patterns/human-in-the-loop.mdx b/docs/content/docs/cookbook/agent-patterns/human-in-the-loop.mdx
index a1e5182b7a..f3c4de9054 100644
--- a/docs/content/docs/cookbook/agent-patterns/human-in-the-loop.mdx
+++ b/docs/content/docs/cookbook/agent-patterns/human-in-the-loop.mdx
@@ -7,165 +7,141 @@ summary: Use defineHook with the tool call ID to suspend an agent for human appr
Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds.
-## Pattern
-
-Create a typed hook using `defineHook()`. When the agent calls the approval tool, the tool creates a hook instance using the tool call ID as the token, then awaits it. The UI renders approval controls, and an API route resumes the hook with the decision.
-
-### Simplified
+## When to use this
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { defineHook, sleep, getWritable } from "workflow";
-import { z } from "zod";
-import type { ModelMessage, UIMessageChunk } from "ai";
-
-export const bookingApprovalHook = defineHook({
- schema: z.object({
- approved: z.boolean(),
- comment: z.string().optional(),
- }),
-});
-
-declare function confirmBooking(args: { flightId: string; passenger: string }): Promise<{ confirmationId: string }>; // @setup
-
-// This tool runs at the workflow level (no "use step") because hooks are workflow primitives
-async function requestBookingApproval(
- { flightId, passenger, price }: { flightId: string; passenger: string; price: number },
- { toolCallId }: { toolCallId: string }
-) {
- const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight]
-
- const result = await Promise.race([ // [!code highlight]
- hook.then((payload) => ({ type: "decision" as const, ...payload })),
- sleep("24h").then(() => ({ type: "timeout" as const, approved: false })),
- ]);
+- Booking confirmations where users must approve before charges are made
+- Content publishing gates where an editor must sign off
+- Any agent action where the cost of getting it wrong justifies a human check
+- Actions with side effects that can't be easily undone
- if (result.type === "timeout") return "Booking request expired after 24 hours.";
- if (!result.approved) return `Booking rejected: ${result.comment || "No reason given"}`;
-
- const booking = await confirmBooking({ flightId, passenger });
- return `Booked! Confirmation: ${booking.confirmationId}`;
-}
-
-export async function bookingAgent(messages: ModelMessage[]) {
- "use workflow";
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- instructions: "You help book flights. Always request approval before booking.",
- tools: {
- requestBookingApproval: {
- description: "Request human approval before booking a flight",
- inputSchema: z.object({
- flightId: z.string(),
- passenger: z.string(),
- price: z.number(),
- }),
- execute: requestBookingApproval,
- },
- },
- });
+## Pattern
- await agent.stream({ // [!code highlight]
- messages,
- writable: getWritable(),
- });
-}
-```
+Create a typed hook using `defineHook()`. When the agent calls the approval tool, the tool emits a custom data part to the stream so the client can render approval controls, then creates a hook and suspends. An API route resumes the hook with the decision.
-### Full Implementation
+### Workflow
-```typescript lineNumbers
+```typescript
import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, sleep, getWritable } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";
-// Define the approval hook with schema validation
-export const bookingApprovalHook = defineHook({
+// Exported so the approval API route can call .resume()
+export const bookingApprovalHook = defineHook({ // [!code highlight]
schema: z.object({
approved: z.boolean(),
comment: z.string().optional(),
}),
});
-// Step: Search for flights (full Node.js access, automatic retries)
-async function searchFlights({
- from,
- to,
- date,
-}: {
+async function searchFlights({ from, to, date }: {
from: string;
to: string;
date: string;
}) {
"use step";
+ const res = await fetch(
+ `https://api.example.com/flights?from=${from}&to=${to}&date=${date}`
+ );
+ return res.json();
+}
- // Your real flight search API call here
- await new Promise((resolve) => setTimeout(resolve, 500));
- return {
- flights: [
- { id: "FL-100", airline: "Example Air", price: 299, from, to, date },
- { id: "FL-200", airline: "Demo Airlines", price: 349, from, to, date },
- ],
- };
+async function confirmBooking({ flightId, passenger }: {
+ flightId: string;
+ passenger: string;
+}) {
+ "use step";
+ const res = await fetch("https://api.example.com/bookings", {
+ method: "POST",
+ body: JSON.stringify({ flightId, passenger }),
+ });
+ return res.json();
}
-// Step: Confirm the booking after approval
-async function confirmBooking({
- flightId,
- passenger,
-}: {
+// Stream a custom data part so the client can render the approval UI.
+// This MUST run before the hook suspends the workflow — otherwise
+// the tool-invocation won't appear in the stream until the tool returns,
+// and the client would have no way to show approval buttons.
+async function emitApprovalRequest(details: {
flightId: string;
passenger: string;
+ price: number;
+ toolCallId: string;
}) {
"use step";
+ const writer = getWritable().getWriter();
+ try {
+ await writer.write({
+ type: "data-approval-needed", // [!code highlight]
+ id: details.toolCallId,
+ data: details,
+ } as UIMessageChunk);
+ } finally {
+ writer.releaseLock();
+ }
+}
- await new Promise((resolve) => setTimeout(resolve, 500));
- return { confirmationId: `CONF-${flightId}-${Date.now().toString(36)}` };
+// Stream the resolution so the client can update the approval card.
+async function emitApprovalResolved(details: {
+ toolCallId: string;
+ result: string;
+}) {
+ "use step";
+ const writer = getWritable().getWriter();
+ try {
+ await writer.write({
+ type: "data-approval-resolved", // [!code highlight]
+ id: details.toolCallId,
+ data: details,
+ } as UIMessageChunk);
+ } finally {
+ writer.releaseLock();
+ }
}
-// Workflow-level tool: hooks must be created in workflow context, not inside steps
+// No "use step" — hooks are workflow-level primitives
async function requestBookingApproval(
- {
- flightId,
- passenger,
- price,
- }: { flightId: string; passenger: string; price: number },
+ { flightId, passenger, price }: {
+ flightId: string;
+ passenger: string;
+ price: number;
+ },
{ toolCallId }: { toolCallId: string }
) {
- // No "use step" — hooks are workflow-level primitives
+ // Emit to the stream before suspending so the UI can show buttons
+ await emitApprovalRequest({ flightId, passenger, price, toolCallId }); // [!code highlight]
- const hook = bookingApprovalHook.create({ token: toolCallId }); // [!code highlight]
+ const hook = bookingApprovalHook.create({ token: toolCallId });
- // Race: human approval vs. 24-hour timeout
- const result = await Promise.race([ // [!code highlight]
+ // Race: human decision vs. timeout
+ const result = await Promise.race([
hook.then((payload) => ({ type: "decision" as const, ...payload })),
- sleep("24h").then(() => ({ type: "timeout" as const, approved: false })),
+ sleep("24h").then(() => ({ type: "timeout" as const, approved: false as const })),
]);
if (result.type === "timeout") {
- return "Booking request expired after 24 hours.";
+ const msg = "Booking request expired.";
+ await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
+ return msg;
}
-
if (!result.approved) {
- return `Booking rejected: ${result.comment || "No reason given"}`;
+ const msg = `Rejected: ${result.comment || "No reason given"}`;
+ await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
+ return msg;
}
- // Approved — proceed with booking
const booking = await confirmBooking({ flightId, passenger });
- return `Flight ${flightId} booked for ${passenger}. Confirmation: ${booking.confirmationId}`;
+ const msg = `Booked! Confirmation: ${booking.confirmationId}`;
+ await emitApprovalResolved({ toolCallId, result: msg }); // [!code highlight]
+ return msg;
}
export async function bookingAgent(messages: ModelMessage[]) {
"use workflow";
- const writable = getWritable();
-
const agent = new DurableAgent({
model: "anthropic/claude-haiku-4.5",
- instructions:
- "You are a flight booking assistant. Search for flights, then request approval before booking.",
+ instructions: "You help book flights. Always request approval before booking.",
tools: {
searchFlights: {
description: "Search for available flights",
@@ -188,86 +164,86 @@ export async function bookingAgent(messages: ModelMessage[]) {
},
});
- await agent.stream({ messages, writable }); // [!code highlight]
+ await agent.stream({
+ messages,
+ writable: getWritable(),
+ });
}
```
-### API Route for Approvals
+### Approval API route
+
+The approval route imports the hook definition and calls `.resume()` with the tool call ID as the token:
-```typescript lineNumbers
-import { bookingApprovalHook } from "@/workflows/booking-agent";
+```typescript
+import { bookingApprovalHook } from "@/app/workflows/booking-agent";
-export async function POST(request: Request) {
- const { toolCallId, approved, comment } = await request.json();
+export async function POST(req: Request) {
+ const { toolCallId, approved, comment } = await req.json();
- // Schema validation happens automatically via defineHook
await bookingApprovalHook.resume(toolCallId, { approved, comment }); // [!code highlight]
return Response.json({ success: true });
}
```
-### Approval Component
-
-```tsx lineNumbers
-"use client";
+### Client rendering
+
+Listen for `data-approval-needed` and `data-approval-resolved` custom data parts in the message stream. The approval tool invocation itself won't appear until the tool returns, so the custom data parts are the mechanism for showing and updating the approval UI.
+
+```tsx
+// Scan all messages for the resolution
+const approvalResult = messages
+ .flatMap((m) => m.parts)
+ .find((p) => p.type === "data-approval-resolved")
+ ?.data?.result;
+
+// In your message parts loop:
+{message.parts.map((part, i) => {
+ if (part.type === "data-approval-needed") { // [!code highlight]
+ const { flightId, passenger, price, toolCallId } = part.data;
+ if (approvalResult) {
+ return
+ );
+ }
+ // Hide the requestBookingApproval tool-invocation part
+ if (part.type === "tool-invocation" &&
+ part.toolInvocation.toolName === "requestBookingApproval") {
+ return null;
+ }
+ // ... other part types
+})}
+```
-import { useState } from "react";
+## How it works
-export function BookingApproval({
- toolCallId,
- input,
- output,
-}: {
- toolCallId: string;
- input?: { flightId: string; passenger: string; price: number };
- output?: string;
-}) {
- const [comment, setComment] = useState("");
- const [isSubmitting, setIsSubmitting] = useState(false);
+1. **`defineHook()` with schema** — creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it.
+2. **`toolCallId` as token** — the approval tool uses the tool call ID as the hook token, naturally linking the hook to the specific tool invocation.
+3. **`emitApprovalRequest` step** — writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this, the client would never see the approval controls because tool invocations don't stream until the tool returns.
+4. **No `"use step"` on the approval tool** — the tool runs at the workflow level because `defineHook().create()` is a workflow primitive. It calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, `confirmBooking`) for I/O.
+5. **`Promise.race` with sleep** — the approval races against a durable timeout. If nobody responds, the workflow continues with an expiration message.
+6. **`emitApprovalResolved` step** — writes the outcome to the stream so the client can update the card immediately, without waiting for the tool-invocation result.
- if (output) {
- return
- );
-}
-```
+- **Change the approval schema** — add fields like `reason`, `amount`, `reviewerEmail` to match your domain.
+- **Multiple approval gates** — the pattern works for any number of tools. Each tool creates its own hook with its own `toolCallId`.
+- **Escalation** — if the first approver doesn't respond, use `sleep()` + another hook to escalate to a backup reviewer.
+- **Adjust timeout** — use `"24h"` for production, shorter durations for demos.
+- **Workflow-level vs step tools** — tools that use `sleep()`, `defineHook()`, or other workflow primitives must NOT use `"use step"`. Tools with only I/O (API calls, DB queries) should use `"use step"` for retries.
## Key APIs
@@ -275,4 +251,5 @@ export function BookingApproval({
- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry
+- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — durable agent with tool definitions
diff --git a/docs/content/docs/cookbook/agent-patterns/meta.json b/docs/content/docs/cookbook/agent-patterns/meta.json
index edf26cce5b..3530caec34 100644
--- a/docs/content/docs/cookbook/agent-patterns/meta.json
+++ b/docs/content/docs/cookbook/agent-patterns/meta.json
@@ -1,10 +1,4 @@
{
"title": "Agent Patterns",
- "pages": [
- "durable-agent",
- "tool-streaming",
- "human-in-the-loop",
- "tool-orchestration",
- "stop-workflow"
- ]
+ "pages": ["durable-agent", "human-in-the-loop", "agent-cancellation"]
}
diff --git a/docs/content/docs/cookbook/agent-patterns/stop-workflow.mdx b/docs/content/docs/cookbook/agent-patterns/stop-workflow.mdx
deleted file mode 100644
index f06ba9ed2f..0000000000
--- a/docs/content/docs/cookbook/agent-patterns/stop-workflow.mdx
+++ /dev/null
@@ -1,216 +0,0 @@
----
-title: Stop Workflow
-description: Gracefully cancel a running agent workflow using a hook signal.
-type: guide
-summary: Use a hook as a stop signal to break out of an agent loop and close the stream cleanly.
----
-
-Use this pattern when you need to gracefully stop a running agent from the outside — for example, a "Stop" button in a chat UI or an admin cancellation endpoint. The workflow listens for a stop signal via a hook while the agent runs, and breaks out of the loop when the signal arrives.
-
-## Pattern
-
-Create a hook with a known token (the run ID). Listen for a stop signal in a non-blocking `.then()`. In the `prepareStep` callback, check the flag and return `{ toolChoice: "none" }` to prevent further tool calls, causing the agent to generate a final response and exit the loop cleanly.
-
-### Simplified
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
-import { z } from "zod";
-import type { ModelMessage, UIMessageChunk } from "ai";
-
-export const stopHook = defineHook({
- schema: z.object({ reason: z.string().optional() }),
-});
-
-declare function processQuery(args: { query: string }): Promise; // @setup
-
-export async function stoppableAgent(messages: ModelMessage[]) {
- "use workflow";
-
- const { workflowRunId } = getWorkflowMetadata();
- let stopRequested = false;
-
- const hook = stopHook.create({ token: `stop:${workflowRunId}` }); // [!code highlight]
- hook.then(() => { stopRequested = true; }); // [!code highlight]
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- tools: {
- processQuery: {
- description: "Process a query",
- inputSchema: z.object({ query: z.string() }),
- execute: processQuery,
- },
- },
- });
-
- const result = await agent.stream({
- messages,
- writable: getWritable(),
- prepareStep: () => { // [!code highlight]
- if (stopRequested) return { toolChoice: "none" }; // [!code highlight]
- return {};
- },
- });
-
- return { messages: result.messages, stopped: stopRequested };
-}
-```
-
-### Full Implementation
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
-import { z } from "zod";
-import type { ModelMessage, UIMessageChunk } from "ai";
-
-// Hook to signal the workflow to stop
-export const stopHook = defineHook({
- schema: z.object({
- reason: z.string().optional(),
- }),
-});
-
-// Step: Search the web
-async function searchWeb({ query }: { query: string }) {
- "use step";
-
- await new Promise((resolve) => setTimeout(resolve, 1000));
- return { results: [`Result for "${query}"`] };
-}
-
-// Step: Analyze data
-async function analyzeData({ data }: { data: string }) {
- "use step";
-
- await new Promise((resolve) => setTimeout(resolve, 800));
- return { analysis: `Analysis of: ${data}` };
-}
-
-// Step: Write the final close marker to the stream
-async function closeStream() {
- "use step";
-
- const writable = getWritable();
- const writer = writable.getWriter();
- try {
- await writer.write({ type: "finish" } as UIMessageChunk);
- } finally {
- writer.releaseLock();
- }
- await writable.close();
-}
-
-export async function stoppableAgent(messages: ModelMessage[]) {
- "use workflow";
-
- const { workflowRunId } = getWorkflowMetadata();
- const writable = getWritable();
-
- // Listen for stop signal using a non-blocking hook
- let stopRequested = false;
- let stopReason: string | undefined;
-
- const hook = stopHook.create({ token: `stop:${workflowRunId}` }); // [!code highlight]
- hook.then(({ reason }) => { // [!code highlight]
- stopRequested = true;
- stopReason = reason;
- });
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- instructions: "You are a research assistant. Search and analyze data as needed.",
- tools: {
- searchWeb: {
- description: "Search the web for information",
- inputSchema: z.object({ query: z.string() }),
- execute: searchWeb,
- },
- analyzeData: {
- description: "Analyze a piece of data",
- inputSchema: z.object({ data: z.string() }),
- execute: analyzeData,
- },
- },
- });
-
- const result = await agent.stream({
- messages,
- writable,
- preventClose: true,
- maxSteps: 20,
- prepareStep: ({ stepNumber }) => { // [!code highlight]
- // Check stop flag before each agent step.
- // Setting toolChoice to "none" prevents tool calls,
- // causing the agent to generate a final response and exit.
- if (stopRequested) {
- return { toolChoice: "none" }; // [!code highlight]
- }
- return {};
- },
- });
-
- // Clean up: close the stream
- await closeStream();
-
- return {
- messages: result.messages,
- stopped: stopRequested,
- stopReason,
- stepsCompleted: result.steps.length,
- };
-}
-```
-
-### API Route to Trigger Stop
-
-```typescript lineNumbers
-import { stopHook } from "@/workflows/stoppable-agent";
-
-export async function POST(
- request: Request,
- { params }: { params: Promise<{ runId: string }> }
-) {
- const { runId } = await params;
- const { reason } = await request.json();
-
- await stopHook.resume(`stop:${runId}`, { // [!code highlight]
- reason: reason || "User requested stop",
- });
-
- return Response.json({ success: true });
-}
-```
-
-### Client Stop Button
-
-```tsx lineNumbers
-"use client";
-
-export function StopButton({ runId }: { runId: string }) {
- const handleStop = async () => {
- await fetch(`/api/chat/${runId}/stop`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reason: "User clicked stop" }),
- });
- };
-
- return (
-
- );
-}
-```
-
-## Key APIs
-
-- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
-- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries
-- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal
-- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
-- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream output and close cleanly on stop
-- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — `prepareStep` callback to check stop flag before each step
diff --git a/docs/content/docs/cookbook/agent-patterns/tool-orchestration.mdx b/docs/content/docs/cookbook/agent-patterns/tool-orchestration.mdx
deleted file mode 100644
index 58d5d23c19..0000000000
--- a/docs/content/docs/cookbook/agent-patterns/tool-orchestration.mdx
+++ /dev/null
@@ -1,255 +0,0 @@
----
-title: Tool Orchestration
-description: Choose between step-level and workflow-level tools, or combine both for complex tool implementations.
-type: guide
-summary: Implement tools as steps for retries and I/O, at the workflow level for sleep and hooks, or combine both.
----
-
-Use this pattern to understand when to implement a tool as a step, at the workflow level, or as a combination. The choice depends on whether the tool needs Node.js I/O (step), workflow primitives like `sleep()` and hooks (workflow level), or both.
-
-## Pattern
-
-Tools marked with `"use step"` get automatic retries and full Node.js access but cannot use `sleep()` or hooks. Tools without `"use step"` run in the workflow context and can use workflow primitives but cannot perform side effects directly. Combine both by having a workflow-level tool call into steps for I/O.
-
-### Step-Level vs Workflow-Level
-
-| Capability | Step (`"use step"`) | Workflow Level |
-|------------|---------------------|----------------|
-| `getWritable()` | Yes | Yes |
-| Automatic retries | Yes | No |
-| Side effects (fetch, DB) | Yes | No |
-| `sleep()` | No | Yes |
-| `createHook()` / `createWebhook()` | No | Yes |
-
-### Simplified
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { sleep, getWritable } from "workflow";
-import { z } from "zod";
-import type { UIMessageChunk } from "ai";
-
-// Step-level tool: I/O with retries
-async function fetchWeather({ city }: { city: string }) {
- "use step";
- const res = await fetch(`https://api.weather.com?city=${city}`);
- return res.json();
-}
-
-// Workflow-level tool: uses sleep()
-async function scheduleReminder({ delayMs }: { delayMs: number }) {
- // No "use step" — sleep() requires workflow context
- await sleep(delayMs); // [!code highlight]
- return { message: `Reminder fired after ${delayMs}ms` };
-}
-
-// Combined: workflow-level orchestration calling into steps
-async function fetchWithDelay({ url, delayMs }: { url: string; delayMs: number }) {
- const result = await doFetch(url); // Step handles I/O // [!code highlight]
- await sleep(delayMs); // Workflow handles sleep // [!code highlight]
- return result;
-}
-
-async function doFetch(url: string) {
- "use step";
- const res = await fetch(url);
- return res.json();
-}
-
-export async function assistantAgent(userMessage: string) {
- "use workflow";
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- tools: {
- fetchWeather: {
- description: "Get weather for a city",
- inputSchema: z.object({ city: z.string() }),
- execute: fetchWeather,
- },
- scheduleReminder: {
- description: "Set a reminder after a delay",
- inputSchema: z.object({ delayMs: z.number() }),
- execute: scheduleReminder,
- },
- fetchWithDelay: {
- description: "Fetch a URL then wait before returning",
- inputSchema: z.object({ url: z.string(), delayMs: z.number() }),
- execute: fetchWithDelay,
- },
- },
- });
-
- await agent.stream({ // [!code highlight]
- messages: [{ role: "user", content: userMessage }],
- writable: getWritable(),
- });
-}
-```
-
-### Full Implementation
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { sleep, createWebhook, getWritable } from "workflow";
-import { z } from "zod";
-import type { UIMessageChunk } from "ai";
-
-// --- Step-level tools: I/O with retries ---
-
-async function searchDatabase({ query }: { query: string }) {
- "use step";
-
- const response = await fetch(`https://api.example.com/search?q=${query}`);
- if (!response.ok) throw new Error(`Search failed: ${response.status}`);
- return response.json();
-}
-
-async function sendNotification({
- userId,
- message,
-}: {
- userId: string;
- message: string;
-}) {
- "use step";
-
- await fetch("https://api.example.com/notifications", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ userId, message }),
- });
- return { sent: true };
-}
-
-// --- Workflow-level tool: uses sleep ---
-
-async function waitThenCheck({
- delayMs,
- endpoint,
-}: {
- delayMs: number;
- endpoint: string;
-}) {
- // No "use step" — workflow context needed for sleep()
- await sleep(delayMs); // [!code highlight]
- // Delegate I/O to a step
- return pollEndpoint(endpoint);
-}
-
-async function pollEndpoint(endpoint: string) {
- "use step";
- const res = await fetch(endpoint);
- return res.json();
-}
-
-// --- Workflow-level tool: uses webhook ---
-
-async function waitForCallback({ description }: { description: string }) {
- // No "use step" — webhooks are workflow primitives
- const webhook = createWebhook(); // [!code highlight]
- // Log the URL so external systems can call it
- console.log(`Waiting for callback at: ${webhook.url}`);
-
- const result = await Promise.race([ // [!code highlight]
- webhook.then((req) => req.json()),
- sleep("1h").then(() => ({ status: "timeout" })),
- ]);
-
- return result;
-}
-
-// --- Combined tool: step I/O + workflow sleep + step I/O ---
-
-async function retryWithCooldown({
- url,
- maxAttempts,
-}: {
- url: string;
- maxAttempts: number;
-}) {
- for (let i = 0; i < maxAttempts; i++) {
- const result = await attemptFetch(url);
- if (result.success) return result;
- if (i < maxAttempts - 1) {
- await sleep(`${(i + 1) * 5}s`); // Increasing cooldown between attempts // [!code highlight]
- }
- }
- return { success: false, error: "All attempts failed" };
-}
-
-async function attemptFetch(url: string) {
- "use step";
- try {
- const res = await fetch(url);
- if (!res.ok) return { success: false, status: res.status };
- return { success: true, data: await res.json() };
- } catch {
- return { success: false, error: "Network error" };
- }
-}
-
-export async function orchestrationAgent(userMessage: string) {
- "use workflow";
-
- const writable = getWritable();
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- instructions:
- "You are an assistant with access to search, notifications, polling, callbacks, and retry tools.",
- tools: {
- searchDatabase: {
- description: "Search the database",
- inputSchema: z.object({ query: z.string() }),
- execute: searchDatabase,
- },
- sendNotification: {
- description: "Send a notification to a user",
- inputSchema: z.object({
- userId: z.string(),
- message: z.string(),
- }),
- execute: sendNotification,
- },
- waitThenCheck: {
- description: "Wait for a duration then check an endpoint",
- inputSchema: z.object({
- delayMs: z.number().describe("Milliseconds to wait"),
- endpoint: z.string().describe("URL to check after waiting"),
- }),
- execute: waitThenCheck,
- },
- waitForCallback: {
- description: "Create a webhook and wait for an external system to call it",
- inputSchema: z.object({
- description: z.string().describe("What the callback is for"),
- }),
- execute: waitForCallback,
- },
- retryWithCooldown: {
- description: "Fetch a URL with retries and increasing cooldown between attempts",
- inputSchema: z.object({
- url: z.string(),
- maxAttempts: z.number().default(3),
- }),
- execute: retryWithCooldown,
- },
- },
- });
-
- await agent.stream({ // [!code highlight]
- messages: [{ role: "user", content: userMessage }],
- writable,
- });
-}
-```
-
-## Key APIs
-
-- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) — declares the orchestrator function
-- [`"use step"`](/docs/api-reference/workflow/use-step) — declares step functions with retries and Node.js access
-- [`sleep()`](/docs/api-reference/workflow/sleep) — durable pause (only in workflow context)
-- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — wait for external HTTP callbacks (only in workflow context)
-- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream data from steps
-- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — agent with mixed step/workflow-level tools
diff --git a/docs/content/docs/cookbook/agent-patterns/tool-streaming.mdx b/docs/content/docs/cookbook/agent-patterns/tool-streaming.mdx
deleted file mode 100644
index 0500587c17..0000000000
--- a/docs/content/docs/cookbook/agent-patterns/tool-streaming.mdx
+++ /dev/null
@@ -1,181 +0,0 @@
----
-title: Tool Streaming
-description: Stream real-time progress updates from tools to the UI while they execute.
-type: guide
-summary: Emit custom data parts from step functions to show incremental results during long-running tool calls.
----
-
-Use this pattern when tools take a long time to execute and you want to show progress updates, intermediate results, or status messages in the UI while the tool is still running.
-
-## Pattern
-
-Inside a step function, call `getWritable()` to write custom data parts to the same stream the agent uses. These appear as typed data parts in the client's message parts array.
-
-### Simplified
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { getWritable } from "workflow";
-import { z } from "zod";
-import type { UIMessageChunk } from "ai";
-
-declare function performSearch(query: string): Promise<{ id: string; title: string }[]>; // @setup
-declare function searchWithProgress(args: { query: string }): Promise; // @setup
-
-export async function searchAgent(userMessage: string) {
- "use workflow";
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- tools: {
- search: {
- description: "Search for items",
- inputSchema: z.object({ query: z.string() }),
- execute: searchWithProgress,
- },
- },
- });
-
- await agent.stream({ // [!code highlight]
- messages: [{ role: "user", content: userMessage }],
- writable: getWritable(),
- });
-}
-```
-
-### Full Implementation
-
-```typescript lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { getWritable } from "workflow";
-import { z } from "zod";
-import type { UIMessageChunk } from "ai";
-
-// Custom data part type for the client to render
-interface FoundItemDataPart {
- type: "data-found-item";
- id: string;
- data: {
- title: string;
- score: number;
- };
-}
-
-// Step: Search with streaming progress updates
-async function searchWithProgress(
- { query }: { query: string },
- { toolCallId }: { toolCallId: string }
-) {
- "use step";
-
- const writable = getWritable(); // [!code highlight]
- const writer = writable.getWriter();
-
- try {
- // Simulate finding items one at a time
- const items = [
- { title: "Result A", score: 95 },
- { title: "Result B", score: 87 },
- { title: "Result C", score: 72 },
- ];
-
- for (const item of items) {
- // Simulate search latency
- await new Promise((resolve) => setTimeout(resolve, 800));
-
- // Stream each result to the UI as it's found
- await writer.write({ // [!code highlight]
- type: "data-found-item",
- id: `${toolCallId}-${item.title}`,
- data: item,
- } as UIMessageChunk);
- }
-
- return {
- message: `Found ${items.length} results for "${query}"`,
- items,
- };
- } finally {
- writer.releaseLock();
- }
-}
-
-// Step: Fetch details for a specific item
-async function getItemDetails({ itemId }: { itemId: string }) {
- "use step";
-
- const writable = getWritable();
- const writer = writable.getWriter();
-
- try {
- // Emit a transient progress message
- await writer.write({ // [!code highlight]
- type: "data-progress",
- data: { message: `Loading details for ${itemId}...` },
- transient: true,
- } as UIMessageChunk);
-
- await new Promise((resolve) => setTimeout(resolve, 1000));
-
- return { itemId, description: "Detailed information", available: true };
- } finally {
- writer.releaseLock();
- }
-}
-
-export async function searchAgent(userMessage: string) {
- "use workflow";
-
- const writable = getWritable();
-
- const agent = new DurableAgent({
- model: "anthropic/claude-haiku-4.5",
- instructions: "You help users search for items. Use the search tool first, then get details if asked.",
- tools: {
- search: {
- description: "Search for items matching a query",
- inputSchema: z.object({
- query: z.string().describe("Search query"),
- }),
- execute: searchWithProgress,
- },
- getDetails: {
- description: "Get detailed information about a specific item",
- inputSchema: z.object({
- itemId: z.string().describe("Item ID from search results"),
- }),
- execute: getItemDetails,
- },
- },
- });
-
- await agent.stream({ // [!code highlight]
- messages: [{ role: "user", content: userMessage }],
- writable,
- });
-}
-```
-
-### Client Rendering
-
-```tsx lineNumbers
-// In your chat component's message rendering:
-{message.parts.map((part, i) => {
- if (part.type === "data-found-item") {
- const item = part.data as { title: string; score: number };
- return (
-
-
{item.title}
-
Score: {item.score}
-
- );
- }
- // ... other part types
-})}
-```
-
-## Key APIs
-
-- [`"use step"`](/docs/api-reference/workflow/use-step) — step functions can write to the stream
-- [`getWritable()`](/docs/api-reference/workflow/get-writable) — access the run's output stream from inside a step
-- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — agent streams LLM output to the same writable
diff --git a/docs/content/docs/cookbook/common-patterns/batching.mdx b/docs/content/docs/cookbook/common-patterns/batching.mdx
index 2dd6ad3539..a4ed596916 100644
--- a/docs/content/docs/cookbook/common-patterns/batching.mdx
+++ b/docs/content/docs/cookbook/common-patterns/batching.mdx
@@ -9,167 +9,93 @@ Use batching when you need to process a large list of items in parallel while co
## When to use this
-- Processing hundreds or thousands of items (orders, images, records)
+- Bulk data imports (contacts, orders, products from a CSV)
+- Processing hundreds or thousands of items against external APIs
- Calling rate-limited APIs where you need to control concurrency
- Any fan-out where you want failure isolation between groups
+## How it works
+
+1. Records are split into fixed-size batches.
+2. Each batch runs in parallel via `Promise.allSettled` — failures in one record don't affect others.
+3. A `sleep()` between batches paces requests to avoid overloading downstream services.
+4. After all batches, a summary is returned with succeeded/failed counts.
+
## Pattern
-The workflow splits items into chunks and processes each chunk with `Promise.allSettled()`. A `sleep()` between chunks prevents overloading downstream services.
+The workflow splits records into chunks, processes each chunk concurrently, tracks results per batch, and returns a final tally.
```typescript
import { sleep } from "workflow";
-declare function processItem(item: string): Promise<{ item: string; ok: boolean }>; // @setup
+type Record = { name: string; email: string; role: string };
+
+declare function processRecord(record: Record): Promise; // @setup
-export async function processBatch(items: string[], batchSize: number = 5) {
+export async function batchImport(records: Record[], batchSize: number) {
"use workflow";
- const results = [];
+ let totalSucceeded = 0;
+ let totalFailed = 0;
- for (let i = 0; i < items.length; i += batchSize) {
- const batch = items.slice(i, i + batchSize);
+ for (let i = 0; i < records.length; i += batchSize) {
+ const batch = records.slice(i, i + batchSize);
- // Run batch in parallel -- failures are isolated
+ // Run batch in parallel — failures are isolated per record
const outcomes = await Promise.allSettled( // [!code highlight]
- batch.map((item) => processItem(item))
+ batch.map((record) => processRecord(record))
);
for (let j = 0; j < outcomes.length; j++) {
- const outcome = outcomes[j];
- results.push(
- outcome.status === "fulfilled"
- ? outcome.value
- : { item: batch[j], ok: false, error: String(outcome.reason) }
- );
+ if (outcomes[j].status === "fulfilled") {
+ totalSucceeded++;
+ } else {
+ totalFailed++;
+ }
}
- // Pace between batches to avoid overload
- if (i + batchSize < items.length) {
+ // Pace between batches to avoid overloading downstream
+ if (i + batchSize < records.length) {
await sleep("1s"); // [!code highlight]
}
}
- const succeeded = results.filter((r) => r.ok).length;
- return { total: results.length, succeeded, failed: results.length - succeeded };
+ return { total: records.length, succeeded: totalSucceeded, failed: totalFailed };
}
```
### Step function
-Each item is processed in its own step, giving it full Node.js access and automatic retries.
+Each record is processed in its own step with full Node.js access and automatic retries.
```typescript
-async function processItem(item: string): Promise<{ item: string; ok: boolean }> {
+type Record = { name: string; email: string; role: string };
+
+async function processRecord(record: Record): Promise {
"use step";
- const res = await fetch(`https://api.example.com/process`, {
+ const res = await fetch(`https://api.example.com/contacts`, {
method: "POST",
- body: JSON.stringify({ item }),
+ body: JSON.stringify(record),
});
- if (!res.ok) throw new Error(`Failed to process ${item}`);
- return { item, ok: true };
+ if (!res.ok) throw new Error(`Failed to import ${record.email}`);
+ const { id } = await res.json();
+ return id;
}
```
-## Variations
-
-### Scatter-gather
-
-When you need results from multiple independent sources before continuing, fan out in parallel and collect all results:
-
-```typescript
-export async function scatterGather(query: string) {
- "use workflow";
-
- const [web, database, cache] = await Promise.allSettled([ // [!code highlight]
- searchWeb(query),
- searchDatabase(query),
- searchCache(query),
- ]);
-
- return {
- web: web.status === "fulfilled" ? web.value : null,
- database: database.status === "fulfilled" ? database.value : null,
- cache: cache.status === "fulfilled" ? cache.value : null,
- };
-}
-
-async function searchWeb(query: string): Promise {
- "use step";
- // Full Node.js access -- call external APIs
- const res = await fetch(`https://search.example.com?q=${query}`);
- return res.json();
-}
-
-async function searchDatabase(query: string): Promise {
- "use step";
- // Query your database
- return [`db-result-for-${query}`];
-}
-
-async function searchCache(query: string): Promise {
- "use step";
- return [`cached-result-for-${query}`];
-}
-```
-
-## In-step concurrency control
-
-When you need to process many items against a rate-limited API but want the entire operation to be a single atomic step, batch the work inside the step itself. This keeps the event log clean (one step instead of hundreds) while still controlling concurrency.
-
-```typescript
-async function processConcurrently(
- items: string[],
- processor: (item: string) => Promise,
- maxConcurrent: number = 5,
-): Promise {
- "use step";
- const results: T[] = [];
-
- for (let i = 0; i < items.length; i += maxConcurrent) {
- const batch = items.slice(i, i + maxConcurrent);
- const batchResults = await Promise.all(batch.map(processor)); // [!code highlight]
- results.push(...batchResults);
- }
-
- return results;
-}
-```
-
-Usage in a workflow:
-
-```typescript
-declare function processConcurrently(items: string[], processor: (item: string) => Promise, maxConcurrent?: number): Promise; // @setup
-
-export async function moderateImages(imageUrls: string[]) {
- "use workflow";
-
- const results = await processConcurrently(
- imageUrls,
- async (url) => {
- const res = await fetch("https://api.example.com/moderate", {
- method: "POST",
- body: JSON.stringify({ url }),
- });
- return res.json();
- },
- 3, // max 3 concurrent API calls
- );
-
- return { total: results.length, results };
-}
-```
+## Adapting to your use case
-**When to use in-step batching vs workflow-level batching:**
-- **Workflow-level** (the pattern above): Each item is its own step with independent retries and failure isolation. Use when items are independent and individual failures should be retried.
-- **In-step**: All items are processed in one step. Use when the items are tightly coupled (e.g., moderating all thumbnails for a single video) or when you want to minimize step overhead for large item counts.
+- Replace the `Record` type with your actual data shape (orders, images, products, etc.).
+- Replace `processRecord()` with your real import logic — DB upserts, API calls, file processing.
+- Tune `batchSize` and the `sleep()` duration to match your downstream rate limits.
+- Add or remove tracking as needed — the pattern works with any item type.
## Tips
- **Use `Promise.allSettled` over `Promise.all`** when you want to continue even if some items fail. `Promise.all` rejects on the first failure; `allSettled` waits for everything and tells you what failed.
- **Tune batch size to your downstream API limits.** If the API allows 10 concurrent requests, use `batchSize: 10`.
-- **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable -- it survives cold starts.
-- **Each `processItem` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch.
+- **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable — it survives cold starts.
+- **Each `processRecord` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch.
## Key APIs
diff --git a/docs/content/docs/cookbook/common-patterns/content-router.mdx b/docs/content/docs/cookbook/common-patterns/content-router.mdx
deleted file mode 100644
index 326d18f20c..0000000000
--- a/docs/content/docs/cookbook/common-patterns/content-router.mdx
+++ /dev/null
@@ -1,207 +0,0 @@
----
-title: Conditional Routing
-description: Inspect a payload and route it to different step handlers based on its content.
-type: guide
-summary: Classify incoming messages and branch to specialized handlers using standard if/else logic in the workflow function.
----
-
-Use conditional routing when incoming messages need different processing paths depending on their content. A support ticket about billing goes to the billing handler; a bug report goes to engineering. The workflow inspects the payload and branches with standard JavaScript control flow.
-
-## When to use this
-
-- Support ticket routing by category
-- Order processing with different flows per product type
-- Event handling where different event types need different logic
-- Any message-driven system where the handler depends on the content
-
-## Pattern: Content-based router
-
-The workflow classifies the input, then branches with `if`/`else` to call the appropriate step:
-
-```typescript
-declare function classifyTicket(ticketId: string, subject: string): Promise<{ ticketType: string }>; // @setup
-declare function handleBilling(ticketId: string): Promise; // @setup
-declare function handleTechnical(ticketId: string): Promise; // @setup
-declare function handleAccount(ticketId: string): Promise; // @setup
-declare function handleFeedback(ticketId: string): Promise; // @setup
-
-export async function routeTicket(ticketId: string, subject: string) {
- "use workflow";
-
- const { ticketType } = await classifyTicket(ticketId, subject); // [!code highlight]
-
- if (ticketType === "billing") { // [!code highlight]
- await handleBilling(ticketId);
- } else if (ticketType === "technical") {
- await handleTechnical(ticketId);
- } else if (ticketType === "account") {
- await handleAccount(ticketId);
- } else {
- await handleFeedback(ticketId);
- }
-
- return { ticketId, routedTo: ticketType };
-}
-```
-
-### Step functions
-
-Each handler is a separate `"use step"` function. The classification step can use an LLM, keyword matching, or any logic you need:
-
-```typescript
-async function classifyTicket(
- ticketId: string,
- subject: string
-): Promise<{ ticketType: string }> {
- "use step";
-
- // Example: simple keyword classification
- // In production, this could call an LLM or ML model
- const lower = subject.toLowerCase();
- if (lower.includes("invoice") || lower.includes("charge") || lower.includes("refund")) {
- return { ticketType: "billing" };
- }
- if (lower.includes("error") || lower.includes("bug") || lower.includes("crash")) {
- return { ticketType: "technical" };
- }
- if (lower.includes("password") || lower.includes("login") || lower.includes("access")) {
- return { ticketType: "account" };
- }
- return { ticketType: "feedback" };
-}
-
-async function handleBilling(ticketId: string): Promise {
- "use step";
- // Look up billing records, process refund, etc.
-}
-
-async function handleTechnical(ticketId: string): Promise {
- "use step";
- // Create bug report, notify engineering, etc.
-}
-
-async function handleAccount(ticketId: string): Promise {
- "use step";
- // Reset password, update permissions, etc.
-}
-
-async function handleFeedback(ticketId: string): Promise {
- "use step";
- // Log feedback, notify product team, etc.
-}
-```
-
-## Pattern: Enrichment before routing
-
-When downstream handlers need more context than the raw input provides, enrich the message in parallel before routing:
-
-```typescript
-export async function enrichAndRoute(email: string) {
- "use workflow";
-
- // Step 1: Look up base data
- const contact = await lookupContact(email);
-
- // Step 2: Enrich from multiple sources in parallel
- const [crm, social] = await Promise.allSettled([ // [!code highlight]
- fetchCrmData(contact),
- fetchSocialData(contact),
- ]);
-
- const enriched = {
- ...contact,
- crm: crm.status === "fulfilled" ? crm.value : null,
- social: social.status === "fulfilled" ? social.value : null,
- };
-
- // Step 3: Route based on enriched data
- if (enriched.crm?.segment === "enterprise") { // [!code highlight]
- await routeToEnterpriseSales(enriched);
- } else {
- await routeToSelfServe(enriched);
- }
-
- return { email, segment: enriched.crm?.segment ?? "self-serve" };
-}
-
-async function lookupContact(email: string): Promise<{ email: string; domain: string }> {
- "use step";
- return { email, domain: email.split("@")[1] ?? "unknown" };
-}
-
-async function fetchCrmData(contact: { email: string }): Promise<{ segment: string }> {
- "use step";
- const res = await fetch(`https://crm.example.com/lookup?email=${contact.email}`);
- return res.json();
-}
-
-async function fetchSocialData(contact: { email: string }): Promise<{ followers: number }> {
- "use step";
- const res = await fetch(`https://social.example.com/lookup?email=${contact.email}`);
- return res.json();
-}
-
-async function routeToEnterpriseSales(enriched: unknown): Promise {
- "use step";
- // Assign to enterprise sales team
-}
-
-async function routeToSelfServe(enriched: unknown): Promise {
- "use step";
- // Add to self-serve onboarding flow
-}
-```
-
-## Pattern: Multiple event sources
-
-When a workflow must wait for signals from different systems before proceeding, create one hook per source and use `Promise.all` with a deadline:
-
-```typescript
-import { defineHook, sleep } from "workflow";
-
-export const orderSignal = defineHook<{ ok: true }>();
-
-const SIGNALS = ["payment", "inventory", "fraud"] as const;
-
-export async function waitForAllSignals(orderId: string) {
- "use workflow";
-
- const hooks = SIGNALS.map((kind) =>
- orderSignal.create({ token: `${kind}:${orderId}` }) // [!code highlight]
- );
-
- const outcome = await Promise.race([ // [!code highlight]
- Promise.all(hooks).then(() => ({ type: "ready" as const })), // [!code highlight]
- sleep("5m").then(() => ({ type: "timeout" as const })), // [!code highlight]
- ]);
-
- if (outcome.type === "timeout") {
- return { orderId, status: "timeout" };
- }
-
- await shipOrder(orderId);
- return { orderId, status: "shipped" };
-}
-
-async function shipOrder(orderId: string): Promise {
- "use step";
- await fetch(`https://shipping.example.com/ship`, {
- method: "POST",
- body: JSON.stringify({ orderId }),
- });
-}
-```
-
-## Tips
-
-- **Workflow functions use standard JavaScript.** `if`/`else`, `switch`, ternaries -- any branching logic works. No special routing DSL needed.
-- **Each handler is an independent step.** This means each gets its own retries, its own error handling, and its own entry in the event log.
-- **Combine with enrichment** when downstream handlers need data from multiple sources. Fan out enrichment with `Promise.allSettled`, then route on the merged result.
-- **Use `defineHook` for event gateways** when the routing decision depends on external signals arriving asynchronously.
-
-## Key APIs
-
-- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- marks the orchestrator function
-- [`"use step"`](/docs/api-reference/workflow/use-step) -- marks each handler as a durable step
-- [`defineHook()`](/docs/api-reference/workflow/define-hook) -- creates hooks for event gateway patterns
-- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable deadline for event gateways
diff --git a/docs/content/docs/cookbook/common-patterns/fan-out.mdx b/docs/content/docs/cookbook/common-patterns/fan-out.mdx
deleted file mode 100644
index 3eee3e28ff..0000000000
--- a/docs/content/docs/cookbook/common-patterns/fan-out.mdx
+++ /dev/null
@@ -1,208 +0,0 @@
----
-title: Fan-Out & Parallel Delivery
-description: Send a message to multiple channels or recipients in parallel with independent failure handling.
-type: guide
-summary: Fan out an incident alert to Slack, email, SMS, and PagerDuty simultaneously using Promise.allSettled, so a failure in one channel does not block the others.
----
-
-Use fan-out when one event needs to trigger multiple independent actions in parallel. Each action runs as its own step, so failures are isolated -- a Slack outage doesn't prevent the email from sending.
-
-## When to use this
-
-- Incident alerting across multiple channels (Slack, email, SMS, PagerDuty)
-- Notifying a list of recipients determined at runtime
-- Any "broadcast" where each delivery is independent
-
-## Pattern: Static fan-out
-
-Define one step per channel and launch them all with `Promise.allSettled()`:
-
-```typescript
-declare function sendSlackAlert(incidentId: string, message: string): Promise; // @setup
-declare function sendEmailAlert(incidentId: string, message: string): Promise; // @setup
-declare function sendSmsAlert(incidentId: string, message: string): Promise; // @setup
-declare function sendPagerDutyAlert(incidentId: string, message: string): Promise; // @setup
-
-export async function incidentFanOut(incidentId: string, message: string) {
- "use workflow";
-
- const settled = await Promise.allSettled([ // [!code highlight]
- sendSlackAlert(incidentId, message),
- sendEmailAlert(incidentId, message),
- sendSmsAlert(incidentId, message),
- sendPagerDutyAlert(incidentId, message),
- ]); // [!code highlight]
-
- const ok = settled.filter((r) => r.status === "fulfilled").length;
- return { incidentId, delivered: ok, failed: settled.length - ok };
-}
-```
-
-### Step functions
-
-Each channel is a separate `"use step"` function. Steps have full Node.js access and retry automatically on transient failures.
-
-```typescript
-async function sendSlackAlert(incidentId: string, message: string) {
- "use step";
- await fetch("https://hooks.slack.com/services/T.../B.../xxx", {
- method: "POST",
- body: JSON.stringify({ text: `[${incidentId}] ${message}` }),
- });
- return { channel: "slack" };
-}
-
-async function sendEmailAlert(incidentId: string, message: string) {
- "use step";
- await fetch("https://api.sendgrid.com/v3/mail/send", {
- method: "POST",
- headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
- body: JSON.stringify({
- to: [{ email: "oncall@example.com" }],
- subject: `Incident ${incidentId}`,
- content: [{ type: "text/plain", value: message }],
- }),
- });
- return { channel: "email" };
-}
-
-async function sendSmsAlert(incidentId: string, message: string) {
- "use step";
- // Call Twilio or similar SMS provider
- return { channel: "sms" };
-}
-
-async function sendPagerDutyAlert(incidentId: string, message: string) {
- "use step";
- // Call PagerDuty Events API
- return { channel: "pagerduty" };
-}
-```
-
-## Pattern: Dynamic recipient list
-
-When recipients are determined at runtime (e.g., severity-based routing), build the list dynamically:
-
-```typescript
-type Severity = "info" | "warning" | "critical";
-
-const RULES = [
- { channel: "slack", match: () => true },
- { channel: "email", match: (s: Severity) => s === "warning" || s === "critical" },
- { channel: "pagerduty", match: (s: Severity) => s === "critical" },
-];
-
-export async function alertByRecipientList(
- alertId: string,
- message: string,
- severity: Severity
-) {
- "use workflow";
-
- const matched = RULES.filter((r) => r.match(severity)).map((r) => r.channel);
-
- const settled = await Promise.allSettled( // [!code highlight]
- matched.map((channel) => deliverToChannel(channel, alertId, message))
- ); // [!code highlight]
-
- const delivered = settled.filter((r) => r.status === "fulfilled").length;
- return { alertId, severity, matched, delivered, failed: matched.length - delivered };
-}
-
-async function deliverToChannel(
- channel: string,
- alertId: string,
- message: string
-): Promise {
- "use step";
- // Route to the appropriate API based on channel name
- await fetch(`https://notifications.example.com/${channel}`, {
- method: "POST",
- body: JSON.stringify({ alertId, message }),
- });
-}
-```
-
-## Pattern: Publish-subscribe
-
-When subscribers are managed in a registry and filtered by topic:
-
-```typescript
-type Subscriber = { id: string; name: string; topics: string[] };
-
-export async function publishEvent(topic: string, payload: string) {
- "use workflow";
-
- const subscribers = await loadSubscribers();
- const matched = subscribers.filter((sub) => sub.topics.includes(topic));
-
- await Promise.allSettled( // [!code highlight]
- matched.map((sub) => deliverToSubscriber(sub.id, topic, payload))
- ); // [!code highlight]
-
- return { topic, delivered: matched.length, total: subscribers.length };
-}
-
-async function loadSubscribers(): Promise {
- "use step";
- // Load from database or configuration service
- return [
- { id: "sub-1", name: "Order Service", topics: ["orders", "inventory"] },
- { id: "sub-2", name: "Email Notifier", topics: ["orders", "shipping"] },
- { id: "sub-3", name: "Analytics", topics: ["orders", "inventory", "shipping"] },
- ];
-}
-
-async function deliverToSubscriber(
- subscriberId: string,
- topic: string,
- payload: string
-): Promise {
- "use step";
- await fetch(`https://subscribers.example.com/${subscriberId}/deliver`, {
- method: "POST",
- body: JSON.stringify({ topic, payload }),
- });
-}
-```
-
-## Deferred await (background steps)
-
-You don't have to await a step immediately. Start a step, do other work, and collect the result later. This is different from `Promise.all` -- you interleave sequential and parallel work instead of waiting for everything at once.
-
-```typescript
-declare function generateReport(data: Record): Promise; // @setup
-declare function sendNotification(userId: string, message: string): Promise; // @setup
-declare function updateDashboard(userId: string): Promise; // @setup
-
-export async function onboardUser(userId: string, data: Record) {
- "use workflow";
-
- // Start report generation in the background
- const reportPromise = generateReport(data); // [!code highlight]
-
- // Do other work while the report generates
- await sendNotification(userId, "Processing started");
- await updateDashboard(userId);
-
- // Now await the report when we actually need it
- const report = await reportPromise; // [!code highlight]
- return { userId, report };
-}
-```
-
-The workflow runtime tracks the background step like any other. If the workflow replays, the already-completed step returns its cached result instantly.
-
-## Tips
-
-- **Use `Promise.allSettled` over `Promise.all`.** `allSettled` lets you know which channels failed without aborting the others.
-- **Each delivery is an independent step.** Transient failures (e.g., Slack 503) trigger automatic retries without affecting other channels.
-- **Use `FatalError` for permanent failures** (e.g., PagerDuty not configured) to stop retries on that channel while letting others continue.
-- **Dynamic recipient lists** decouple routing from delivery -- adding a new channel is a configuration change, not a code change.
-
-## Key APIs
-
-- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
-- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
-- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- fans out to all targets, isolating failures
-- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- prevents automatic retry for permanent failures
diff --git a/docs/content/docs/cookbook/common-patterns/meta.json b/docs/content/docs/cookbook/common-patterns/meta.json
index e8eaed4c27..3c1ed7e585 100644
--- a/docs/content/docs/cookbook/common-patterns/meta.json
+++ b/docs/content/docs/cookbook/common-patterns/meta.json
@@ -2,15 +2,14 @@
"title": "Common Patterns",
"defaultOpen": true,
"pages": [
+ "sequential-and-parallel",
+ "workflow-composition",
"saga",
"batching",
"rate-limiting",
- "fan-out",
"scheduling",
+ "timeouts",
"idempotency",
- "webhooks",
- "content-router",
- "child-workflows",
- "distributed-abort-controller"
+ "webhooks"
]
}
diff --git a/docs/content/docs/cookbook/common-patterns/saga.mdx b/docs/content/docs/cookbook/common-patterns/saga.mdx
index 2838b570a9..b200d402e9 100644
--- a/docs/content/docs/cookbook/common-patterns/saga.mdx
+++ b/docs/content/docs/cookbook/common-patterns/saga.mdx
@@ -13,6 +13,12 @@ Use the saga pattern when a business transaction spans multiple services and you
- Any sequence where partial completion leaves the system in an inconsistent state
- Operations that need "all or nothing" semantics across external APIs
+## How it works
+
+1. Each forward step does work and registers a compensation function.
+2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency.
+3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help.
+
## Pattern
Each step returns a result and pushes a compensation handler onto a stack. If a later step throws a `FatalError`, the workflow catches it and executes compensations in LIFO order.
@@ -34,23 +40,21 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
const compensations: Array<() => Promise> = [];
try {
- // Step 1: Reserve seats
const reservationId = await reserveSeats(accountId, seats);
compensations.push(() => releaseSeats(accountId, reservationId)); // [!code highlight]
- // Step 2: Capture payment
const invoiceId = await captureInvoice(accountId, seats);
compensations.push(() => refundInvoice(accountId, invoiceId)); // [!code highlight]
- // Step 3: Provision access
const entitlementId = await provisionSeats(accountId, seats);
compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight]
- // Step 4: Notify
+ // No compensation — notifications are fire-and-forget
await sendConfirmation(accountId, invoiceId, entitlementId);
+
return { status: "completed" };
} catch (error) {
- // Unwind compensations in reverse order
+ // Unwind compensations in reverse (LIFO) order
for (const compensate of compensations.reverse()) { // [!code highlight]
await compensate(); // [!code highlight]
}
@@ -62,11 +66,13 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
### Step functions
-Each step is a `"use step"` function with full Node.js access. Forward steps do the work; compensation steps undo it.
+Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback.
```typescript
import { FatalError } from "workflow";
+// Forward steps
+
async function reserveSeats(accountId: string, seats: number): Promise {
"use step";
const res = await fetch(`https://api.example.com/seats/reserve`, {
@@ -78,15 +84,6 @@ async function reserveSeats(accountId: string, seats: number): Promise {
return reservationId;
}
-async function releaseSeats(accountId: string, reservationId: string): Promise {
- "use step";
- // Compensations should be idempotent — safe to call twice
- await fetch(`https://api.example.com/seats/release`, {
- method: "POST",
- body: JSON.stringify({ accountId, reservationId }),
- });
-}
-
async function captureInvoice(accountId: string, seats: number): Promise {
"use step";
const res = await fetch(`https://api.example.com/invoices`, {
@@ -98,14 +95,6 @@ async function captureInvoice(accountId: string, seats: number): Promise
return invoiceId;
}
-async function refundInvoice(accountId: string, invoiceId: string): Promise {
- "use step";
- await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
- method: "POST",
- body: JSON.stringify({ accountId }),
- });
-}
-
async function provisionSeats(accountId: string, seats: number): Promise {
"use step";
const res = await fetch(`https://api.example.com/entitlements`, {
@@ -117,14 +106,6 @@ async function provisionSeats(accountId: string, seats: number): Promise
return entitlementId;
}
-async function deprovisionSeats(accountId: string, entitlementId: string): Promise {
- "use step";
- await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
- method: "DELETE",
- body: JSON.stringify({ accountId }),
- });
-}
-
async function sendConfirmation(
accountId: string,
invoiceId: string,
@@ -136,17 +117,131 @@ async function sendConfirmation(
body: JSON.stringify({ accountId, invoiceId, entitlementId, template: "upgrade-complete" }),
});
}
+
+// Compensation steps — must be idempotent
+
+async function releaseSeats(accountId: string, reservationId: string): Promise {
+ "use step";
+ await fetch(`https://api.example.com/seats/release`, {
+ method: "POST",
+ body: JSON.stringify({ accountId, reservationId }),
+ });
+}
+
+async function refundInvoice(accountId: string, invoiceId: string): Promise {
+ "use step";
+ await fetch(`https://api.example.com/invoices/${invoiceId}/refund`, {
+ method: "POST",
+ body: JSON.stringify({ accountId }),
+ });
+}
+
+async function deprovisionSeats(accountId: string, entitlementId: string): Promise {
+ "use step";
+ await fetch(`https://api.example.com/entitlements/${entitlementId}`, {
+ method: "DELETE",
+ body: JSON.stringify({ accountId }),
+ });
+}
+```
+
+### Streaming step progress (optional)
+
+Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time.
+
+```typescript
+import { FatalError } from "workflow";
+import { getWritable } from "workflow";
+
+type SagaEvent =
+ | { type: "step_start"; step: string }
+ | { type: "step_done"; step: string; detail: string }
+ | { type: "step_failed"; step: string; error: string }
+ | { type: "compensating"; step: string }
+ | { type: "compensated"; step: string }
+ | { type: "result"; status: "completed" | "rolled_back" };
+
+async function emit(event: SagaEvent) {
+ "use step";
+ const writer = getWritable().getWriter();
+ try {
+ await writer.write(event);
+ } finally {
+ writer.releaseLock();
+ }
+}
+
+declare function reserveSeats(accountId: string, seats: number): Promise; // @setup
+declare function releaseSeats(accountId: string, reservationId: string): Promise; // @setup
+declare function captureInvoice(accountId: string, seats: number): Promise; // @setup
+declare function refundInvoice(accountId: string, invoiceId: string): Promise; // @setup
+declare function provisionSeats(accountId: string, seats: number): Promise; // @setup
+declare function deprovisionSeats(accountId: string, entitlementId: string): Promise; // @setup
+declare function sendConfirmation(accountId: string, invoiceId: string, entitlementId: string): Promise; // @setup
+
+export async function subscriptionUpgradeSaga(accountId: string, seats: number) {
+ "use workflow";
+
+ const compensations: Array<{ name: string; execute: () => Promise }> = [];
+
+ try {
+ await emit({ type: "step_start", step: "Reserve Seats" });
+ const reservationId = await reserveSeats(accountId, seats);
+ compensations.push({ name: "Release Seats", execute: () => releaseSeats(accountId, reservationId) });
+ await emit({ type: "step_done", step: "Reserve Seats", detail: reservationId });
+
+ await emit({ type: "step_start", step: "Capture Invoice" });
+ const invoiceId = await captureInvoice(accountId, seats);
+ compensations.push({ name: "Refund Invoice", execute: () => refundInvoice(accountId, invoiceId) });
+ await emit({ type: "step_done", step: "Capture Invoice", detail: invoiceId });
+
+ await emit({ type: "step_start", step: "Provision Seats" });
+ const entitlementId = await provisionSeats(accountId, seats);
+ compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) });
+ await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId });
+
+ // No compensation — notifications are fire-and-forget
+ await emit({ type: "step_start", step: "Send Confirmation" });
+ await sendConfirmation(accountId, invoiceId, entitlementId);
+ await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" });
+
+ await emit({ type: "result", status: "completed" });
+ return { status: "completed" };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+ await emit({ type: "step_failed", step: "failed", error: errorMessage });
+
+ // Unwind compensations in reverse (LIFO) order
+ for (const comp of compensations.reverse()) {
+ await emit({ type: "compensating", step: comp.name });
+ await comp.execute();
+ await emit({ type: "compensated", step: comp.name });
+ }
+
+ await emit({ type: "result", status: "rolled_back" });
+ return { status: "rolled_back" };
+ }
+}
```
+## Adapting to your use case
+
+- Replace the step functions with real API calls. Each `"use step"` function has full Node.js access.
+- Add or remove steps as needed — the pattern scales to any number of steps.
+- Make compensations idempotent — they may be retried if the workflow restarts mid-rollback.
+- The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress.
+
## Tips
- **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input).
- **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again.
- **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off.
- **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state.
+- **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation.
## Key APIs
- [`"use workflow"`](/docs/api-reference/workflow/use-workflow) -- declares the orchestrator function
- [`"use step"`](/docs/api-reference/workflow/use-step) -- declares step functions with full Node.js access
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
+- [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates
diff --git a/docs/content/docs/cookbook/common-patterns/scheduling.mdx b/docs/content/docs/cookbook/common-patterns/scheduling.mdx
index 822742412d..988040a75a 100644
--- a/docs/content/docs/cookbook/common-patterns/scheduling.mdx
+++ b/docs/content/docs/cookbook/common-patterns/scheduling.mdx
@@ -2,248 +2,124 @@
title: Sleep, Scheduling & Timed Workflows
description: Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.
type: guide
-summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events wake the workflow early.
+summary: Schedule future actions with durable sleep that survives cold starts, and race sleeps against hooks to let external events cancel the workflow early.
---
-Workflow's `sleep()` is durable -- it survives cold starts, restarts, and deployments. This makes it the foundation for scheduled actions, drip campaigns, reminders, and any pattern that needs to wait for real-world time to pass.
+Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.
## When to use this
-- Sending emails on a schedule (drip campaigns, reminders, digests)
-- Waiting for a deadline before taking action
-- Any pattern where "do X, wait N hours, then do Y" needs to be reliable
+- Sending emails on a schedule (drip campaigns, onboarding sequences, reminders)
+- Waiting for a deadline but allowing early cancellation
+- Any pattern where "do X, wait N hours, then do Y" needs to be both reliable and interruptible
-## Pattern: Drip campaign
+## Drip campaign with cancellation
-Send emails at scheduled intervals using `sleep()` between steps. The workflow runs for days or weeks, sleeping between each email.
+A drip campaign sends emails at intervals, sleeping between each. Each sleep races against a cancellation hook — if an external event fires the hook (e.g. user converts, unsubscribes), the campaign stops immediately.
```typescript
-import { sleep } from "workflow";
-
-export async function onboardingDrip(email: string) {
- "use workflow";
-
- await sendEmail(email, "welcome");
-
- await sleep("1d"); // [!code highlight]
- await sendEmail(email, "getting-started-tips");
-
- await sleep("2d"); // [!code highlight]
- await sendEmail(email, "feature-highlights");
+import { defineHook, sleep } from "workflow";
- await sleep("4d"); // [!code highlight]
- await sendEmail(email, "follow-up");
-
- return { email, status: "completed", totalDays: 7 };
-}
+// Hook that any API route can fire to cancel the drip
+export const cancelDrip = defineHook<{ reason?: string }>(); // [!code highlight]
async function sendEmail(email: string, template: string): Promise {
"use step";
await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}` },
- body: JSON.stringify({
- to: [{ email }],
- template_id: template,
- }),
+ body: JSON.stringify({ to: [{ email }], template_id: template }),
});
}
-```
-
-## Pattern: Interruptible reminder (sleep vs hook)
-
-Race a `sleep()` against a `defineHook` so external events can cancel, snooze, or send early:
-
-```typescript
-import { defineHook, sleep } from "workflow";
-
-type ReminderAction =
- | { type: "cancel" }
- | { type: "send_now" }
- | { type: "snooze"; seconds: number };
-
-export const reminderActionHook = defineHook();
-export async function scheduleReminder(userId: string, delayMs: number) {
+export async function emailSequence(email: string) {
"use workflow";
- let sendAt = new Date(Date.now() + delayMs);
- const action = reminderActionHook.create({ token: `reminder:${userId}` });
-
- const outcome = await Promise.race([ // [!code highlight]
- sleep(sendAt).then(() => ({ kind: "time" as const })), // [!code highlight]
- action.then((payload) => ({ kind: "action" as const, payload })), // [!code highlight]
- ]);
-
- if (outcome.kind === "action") {
- if (outcome.payload.type === "cancel") {
- return { userId, status: "cancelled" };
- }
- if (outcome.payload.type === "snooze") {
- sendAt = new Date(Date.now() + outcome.payload.seconds * 1000);
- await sleep(sendAt);
- }
- // "send_now" falls through to send immediately
- }
+ await sendEmail(email, "welcome");
- await sendReminderEmail(userId);
- return { userId, status: "sent" };
-}
+ // Race durable sleep against the cancellation hook
+ const hook = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
+ const cancelled = await Promise.race([ // [!code highlight]
+ sleep("2d").then(() => false), // [!code highlight]
+ hook.then(() => true), // [!code highlight]
+ ]); // [!code highlight]
+ if (cancelled) return { status: "cancelled", email };
-async function sendReminderEmail(userId: string): Promise {
- "use step";
- await fetch("https://api.example.com/reminders/send", {
- method: "POST",
- body: JSON.stringify({ userId }),
- });
-}
-```
+ await sendEmail(email, "getting-started-tips");
-To wake the reminder early from an API route:
+ // Create a fresh hook for the next sleep window
+ const hook2 = cancelDrip.create({ token: `cancel-drip:${email}` }); // [!code highlight]
+ const cancelled2 = await Promise.race([ // [!code highlight]
+ sleep("2d").then(() => false), // [!code highlight]
+ hook2.then(() => true), // [!code highlight]
+ ]); // [!code highlight]
+ if (cancelled2) return { status: "cancelled", email };
-```typescript
-import { resumeHook } from "workflow/api";
+ await sendEmail(email, "feature-highlights");
-// POST /api/reminder/cancel
-export async function POST(request: Request) {
- const { userId } = await request.json();
- await resumeHook(`reminder:${userId}`, { type: "cancel" }); // [!code highlight]
- return Response.json({ ok: true });
+ return { status: "drip-complete", email };
}
```
-## Pattern: Timed collection window (digest)
+### Cancelling from an API route
-Open a collection window using `sleep()` and accumulate events from a hook until the window closes:
+Any server-side code can fire the hook by calling `.resume()` with the same token:
```typescript
-import { sleep, defineHook } from "workflow";
-
-type EventPayload = { type: string; message: string };
-
-export const digestEvent = defineHook();
-
-export async function collectAndSendDigest(
- digestId: string,
- userId: string,
- windowMs: number = 3_600_000
-) {
- "use workflow";
+import { cancelDrip } from "@/workflows/email-sequence";
- const hook = digestEvent.create({ token: `digest:${digestId}` });
- const windowClosed = sleep(`${windowMs}ms`).then(() => ({
- kind: "window_closed" as const,
- }));
- const events: EventPayload[] = [];
+export async function POST(req: Request) {
+ const { email, reason } = await req.json();
- while (true) {
- const outcome = await Promise.race([ // [!code highlight]
- hook.then((payload) => ({ kind: "event" as const, payload })),
- windowClosed,
- ]);
-
- if (outcome.kind === "window_closed") break; // [!code highlight]
- events.push(outcome.payload);
- }
-
- if (events.length > 0) {
- await sendDigestEmail(userId, events);
+ if (!email) {
+ return Response.json({ error: "email is required" }, { status: 400 });
}
- return { digestId, status: events.length > 0 ? "sent" : "empty", eventCount: events.length };
-}
-
-async function sendDigestEmail(userId: string, events: EventPayload[]): Promise {
- "use step";
- await fetch("https://api.example.com/digest/send", {
- method: "POST",
- body: JSON.stringify({ userId, events }),
- });
-}
-```
-
-## Pattern: Timeout
-
-Add a timeout to any operation by racing it against `sleep()`:
-
-```typescript
-import { sleep, FatalError } from "workflow";
-
-export async function processWithTimeout(jobId: string) {
- "use workflow";
-
- const result = await Promise.race([ // [!code highlight]
- processData(jobId),
- sleep("30s").then(() => "timeout" as const), // [!code highlight]
- ]);
-
- if (result === "timeout") {
- return { jobId, status: "timed_out" };
+ try {
+ await cancelDrip.resume(`cancel-drip:${email}`, { // [!code highlight]
+ reason: reason ?? "User completed action", // [!code highlight]
+ }); // [!code highlight]
+ } catch (error) {
+ const msg = error instanceof Error ? error.message.toLowerCase() : "";
+ if (msg.includes("not found") || msg.includes("expired")) {
+ return Response.json({
+ success: true,
+ email,
+ note: "No active drip found (already completed or cancelled)",
+ });
+ }
+ throw error;
}
- return { jobId, status: "completed", result };
-}
-
-async function processData(jobId: string): Promise {
- "use step";
- // Long-running computation
- return `result-for-${jobId}`;
+ return Response.json({ success: true, email });
}
```
-## Polling external services
-
-When you need to poll an external service until a job completes, define your own `sleep` as a step function and use it in a polling loop. Each iteration becomes a separate step in the event log, making the entire loop durable.
-
-```typescript
-async function sleep(ms: number): Promise {
- "use step";
- await new Promise(resolve => setTimeout(resolve, ms));
-}
-
-export async function waitForTranscription(jobId: string) {
- "use workflow";
+## How it works
- let status = "processing";
- let attempts = 0;
- const maxAttempts = 36; // ~3 minutes at 5s intervals
+1. **Durable sleep** — `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires.
+2. **Hook creation** — `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token.
+3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
+4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token.
- while (status === "processing" && attempts < maxAttempts) {
- await sleep(5000); // [!code highlight]
- attempts++;
- const result = await checkJobStatus(jobId); // [!code highlight]
- status = result.status;
- }
-
- if (status !== "completed") {
- return { jobId, status: "timed_out", attempts };
- }
-
- return { jobId, status: "completed", attempts };
-}
-
-async function checkJobStatus(jobId: string): Promise<{ status: string }> {
- "use step";
- const res = await fetch(`https://api.example.com/jobs/${jobId}`);
- return res.json();
-}
-```
+## Adapting to your use case
-**When to use this vs `sleep()` from `workflow`:**
-- Use `sleep()` from `workflow` for fixed, known delays (drip campaigns, reminders, cooldowns).
-- Use a custom sleep-as-step for polling loops where you need to check a condition between sleeps. The custom step version also works in libraries that don't want to import from the `workflow` module directly.
+- **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times.
+- **Add more steps** — the pattern scales to any number of email-then-sleep pairs.
+- **Snooze instead of cancel** — resolve the hook with a `snooze` payload and sleep again: `sleep(new Date(Date.now() + payload.snoozeMs))`.
+- **Timeout any operation** — the same `Promise.race(sleep, work)` pattern works for adding deadlines to slow steps.
+- **Real providers** — swap the `sendEmail` step body for Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access.
## Tips
- **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time.
-- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping -- no compute, no memory. It resumes precisely when the timer fires.
-- **Race `sleep` against `defineHook`** for interruptible waits. This is the standard pattern for reminders, approvals with deadlines, and timed collection windows.
-- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use a standard `setTimeout` or return control to the workflow.
+- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping — no compute, no memory.
+- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step.
## Key APIs
-- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
-- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
-- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable wait (survives restarts, zero compute cost while sleeping)
-- [`defineHook`](/docs/api-reference/workflow/define-hook) -- creates a hook that external systems can trigger
-- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) -- races sleep against hooks for interruptible waits
+- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
+- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions that run with full Node.js access
+- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
+- [`defineHook()`](/docs/api-reference/workflow/define-hook) — creates a typed hook that external systems can fire
+- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — races sleep against hooks for interruptible waits
diff --git a/docs/content/docs/cookbook/common-patterns/sequential-and-parallel.mdx b/docs/content/docs/cookbook/common-patterns/sequential-and-parallel.mdx
new file mode 100644
index 0000000000..adb3e535c9
--- /dev/null
+++ b/docs/content/docs/cookbook/common-patterns/sequential-and-parallel.mdx
@@ -0,0 +1,155 @@
+---
+title: Sequential & Parallel Execution
+description: Compose steps with familiar async/await patterns — sequential await, Promise.all, and Promise.race.
+type: guide
+summary: Workflows are just async functions, so all the standard composition primitives (await, Promise.all, Promise.race) apply unchanged — including racing webhooks against durable sleeps.
+related:
+ - /docs/foundations/workflows-and-steps
+ - /cookbook/common-patterns/timeouts
+ - /cookbook/common-patterns/scheduling
+---
+
+Workflows are written in plain async/await — there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also just promises.
+
+## When to use this
+
+- **Pipelines** — each step depends on the previous step's output (validate → process → store)
+- **Independent fan-out** — fetch multiple resources or perform multiple actions that don't depend on each other
+- **Race conditions** — return as soon as one of N operations completes (timeout, first-responder, deadline)
+- **Mixing primitives** — running steps, sleeps, and webhooks side-by-side in the same control-flow expression
+
+## Pattern
+
+### Sequential
+
+The simplest way to orchestrate steps is to execute them one after another, where each step depends on the previous step's output.
+
+```typescript lineNumbers
+declare function validateData(data: unknown): Promise; // @setup
+declare function processData(data: string): Promise; // @setup
+declare function storeData(data: string): Promise; // @setup
+
+export async function dataPipelineWorkflow(data: unknown) {
+ "use workflow";
+
+ const validated = await validateData(data);
+ const processed = await processData(validated);
+ const stored = await storeData(processed);
+
+ return stored;
+}
+```
+
+### Parallel with `Promise.all`
+
+When steps don't depend on each other, run them concurrently with `Promise.all`. The workflow waits until all of them resolve.
+
+```typescript lineNumbers
+declare function fetchUser(userId: string): Promise<{ name: string }>; // @setup
+declare function fetchOrders(userId: string): Promise<{ items: string[] }>; // @setup
+declare function fetchPreferences(userId: string): Promise<{ theme: string }>; // @setup
+
+export async function fetchUserData(userId: string) {
+ "use workflow";
+
+ const [user, orders, preferences] = await Promise.all([ // [!code highlight]
+ fetchUser(userId), // [!code highlight]
+ fetchOrders(userId), // [!code highlight]
+ fetchPreferences(userId), // [!code highlight]
+ ]); // [!code highlight]
+
+ return { user, orders, preferences };
+}
+```
+
+### Race with `Promise.race`
+
+`Promise.race` resolves as soon as the first promise settles. Since [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) return promises, they compose naturally — for example, waiting for a webhook callback with a deadline:
+
+```typescript lineNumbers
+import { sleep, createWebhook } from "workflow";
+
+declare function executeExternalTask(webhookUrl: string): Promise; // @setup
+
+export async function runExternalTask(userId: string) {
+ "use workflow";
+
+ const webhook = createWebhook();
+ await executeExternalTask(webhook.url);
+
+ await Promise.race([ // [!code highlight]
+ webhook, // [!code highlight]
+ sleep("1 day"), // [!code highlight]
+ ]); // [!code highlight]
+
+ console.log("Done");
+}
+```
+
+For racing operations against deadlines specifically (timeouts), see the dedicated [Timeouts](/cookbook/common-patterns/timeouts) recipe — it covers result discrimination, `FatalError` semantics, and the "loser keeps running" caveat.
+
+### Combining sequential, parallel, and durable primitives
+
+Most real workflows combine all three. Here's a simplified version of the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator) — sequential card generation, parallel RSVP fan-out, non-blocking webhook collection, and a durable sleep until the birthday:
+
+```typescript lineNumbers
+import { createWebhook, sleep, type Webhook } from "workflow";
+
+declare function makeCardText(prompt: string): Promise; // @setup
+declare function makeCardImage(text: string): Promise; // @setup
+declare function sendRSVPEmail(friend: string, webhook: Webhook): Promise; // @setup
+declare function sendBirthdayCard(text: string, image: string, rsvps: unknown[], email: string): Promise; // @setup
+
+export async function birthdayWorkflow(
+ prompt: string,
+ email: string,
+ friends: string[],
+ birthday: Date
+) {
+ "use workflow";
+
+ const text = await makeCardText(prompt); // [!code highlight]
+ const image = await makeCardImage(text); // [!code highlight]
+
+ const webhooks = friends.map(() => createWebhook());
+
+ await Promise.all( // [!code highlight]
+ friends.map((friend, i) => sendRSVPEmail(friend, webhooks[i])) // [!code highlight]
+ ); // [!code highlight]
+
+ const rsvps: unknown[] = [];
+ webhooks.map((webhook) =>
+ webhook.then((req) => req.json()).then(({ rsvp }) => rsvps.push(rsvp))
+ );
+
+ await sleep(birthday); // [!code highlight]
+
+ await sendBirthdayCard(text, image, rsvps, email);
+
+ return { text, image, status: "Sent" };
+}
+```
+
+## How it works
+
+1. **`await` is durable.** When the workflow awaits a step, the runtime persists the step's input, suspends the workflow, runs the step, and replays the workflow with the step's result on resume. The same applies to `sleep()` and `createWebhook()`.
+2. **`Promise.all` runs steps concurrently.** Each promise in the array is suspended on its own and the workflow resumes only when all have settled. Failures propagate — if any promise rejects, the whole `Promise.all` rejects.
+3. **`Promise.race` resolves on the first settle.** The losing promises keep running in the background but their results are discarded by the workflow.
+4. **All primitives are promises.** `sleep("1 day")` and `createWebhook()` return promises, so they compose with `Promise.all` / `Promise.race` exactly like steps do — this is what makes patterns like "race a webhook against a 24-hour deadline" a one-liner.
+
+## Adapting to your use case
+
+- **Replace `Promise.all` with `Promise.allSettled`** when partial failures should not abort the rest. You'll get an array of `{ status, value | reason }` instead of throwing on the first rejection.
+- **Bound the parallelism** — `Promise.all` over 1000 items will fan out 1000 concurrent steps. If your downstream APIs can't handle that, batch the array into chunks (see [Batching](/cookbook/common-patterns/batching)).
+- **Add a deadline to any race** — pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts).
+- **Mix steps and hooks in a race** — wait for an external signal *or* a deadline *or* a step result, all in the same `Promise.race`. The first one to resolve wins.
+
+## Key APIs
+
+- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
+- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
+- [`sleep()`](/docs/api-reference/workflow/sleep) — durable sleep that survives restarts
+- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — webhook URL the workflow can race against
+- [`Promise.all()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) — wait for all promises
+- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — wait for the first to settle
+- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) — wait for all, including failures
diff --git a/docs/content/docs/cookbook/common-patterns/timeouts.mdx b/docs/content/docs/cookbook/common-patterns/timeouts.mdx
new file mode 100644
index 0000000000..f5f05c7f16
--- /dev/null
+++ b/docs/content/docs/cookbook/common-patterns/timeouts.mdx
@@ -0,0 +1,99 @@
+---
+title: Timeouts
+description: Add deadlines to slow operations by racing them against a durable sleep.
+type: guide
+summary: Use `Promise.race` with `sleep()` to bound the time any step, hook, or webhook is allowed to take — and recover gracefully when the deadline fires first.
+related:
+ - /docs/api-reference/workflow/sleep
+ - /docs/foundations/hooks
+ - /cookbook/common-patterns/scheduling
+ - /cookbook/common-patterns/webhooks
+---
+
+A common requirement is bounding how long a workflow waits for something to finish — a slow step, an external webhook, a human approval. Race the operation against a durable `sleep()` with `Promise.race()` — whichever finishes first wins, and the loser keeps running but its result is ignored.
+
+## When to use this
+
+- **Slow steps** — bound the time spent waiting on third-party APIs, model calls, or expensive computation
+- **External callbacks** — give webhooks a deadline so the workflow doesn't hang forever waiting for an event that may never arrive
+- **Human approvals** — auto-decline or escalate when a hook isn't resumed within a window
+- **Polling loops** — give an outer poll-until-ready loop an overall budget
+
+## Pattern
+
+### Timeout on a slow step
+
+```typescript lineNumbers
+import { sleep } from "workflow";
+
+declare function processData(data: string): Promise; // @setup
+
+export async function processWithTimeout(data: string) {
+ "use workflow";
+
+ const result = await Promise.race([ // [!code highlight]
+ processData(data), // [!code highlight]
+ sleep("30s").then(() => "timeout" as const), // [!code highlight]
+ ]); // [!code highlight]
+
+ if (result === "timeout") {
+ throw new Error("Processing timed out after 30 seconds");
+ }
+
+ return result;
+}
+```
+
+### Timeout on a webhook
+
+The same pattern works for any promise — including hooks and webhooks. Here a webhook waits for an external service to call back, with a hard deadline of 7 days:
+
+```typescript lineNumbers
+import { sleep, createWebhook } from "workflow";
+
+declare function sendApprovalRequest(requestId: string, webhookUrl: string): Promise; // @setup
+
+export async function waitForApproval(requestId: string) {
+ "use workflow";
+
+ const webhook = createWebhook<{ approved: boolean }>();
+ await sendApprovalRequest(requestId, webhook.url);
+
+ const result = await Promise.race([ // [!code highlight]
+ webhook.then((req) => req.json()), // [!code highlight]
+ sleep("7 days").then(() => ({ timedOut: true }) as const), // [!code highlight]
+ ]); // [!code highlight]
+
+ if ("timedOut" in result) {
+ throw new Error("Approval request expired after 7 days");
+ }
+
+ return result.approved;
+}
+```
+
+## How it works
+
+1. **Durable sleep** — `sleep("30s")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires.
+2. **Race** — `Promise.race([work, sleep(...)])` returns the value of whichever promise resolves first. The loser keeps running in the background but its result is ignored by the workflow.
+3. **Discriminated result** — tagging the sleep branch with a sentinel value (`"timeout" as const`, `{ timedOut: true }`) lets TypeScript narrow the result and pick the right branch.
+4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows.
+
+
+**The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. For hard cancellation across processes, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
+
+
+## Adapting to your use case
+
+- **Different durations** — `sleep()` accepts duration strings (`"30s"`, `"5m"`, `"7 days"`), milliseconds, or `Date` objects for absolute deadlines.
+- **Soft timeout (retry)** — instead of throwing, loop and retry with a fresh `Promise.race` and a backoff.
+- **Soft timeout (fallback)** — return a default value when the timer wins instead of throwing: `if (result === "timeout") return cachedFallback`.
+- **Combine with cancellation** — race three promises: the operation, a deadline `sleep()`, and a cancellation hook. See the [Scheduling cookbook](/cookbook/common-patterns/scheduling) for the cancellation half of this pattern.
+- **Per-step deadlines** — wrap each step in its own `Promise.race` for independent budgets, or use a single outer race for an overall workflow deadline.
+
+## Key APIs
+
+- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
+- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — create a webhook URL the workflow can race against
+- [`defineHook()`](/docs/api-reference/workflow/define-hook) — typed hook for in-process cancellation
+- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — race operations against deadlines
diff --git a/docs/content/docs/cookbook/common-patterns/workflow-composition.mdx b/docs/content/docs/cookbook/common-patterns/workflow-composition.mdx
new file mode 100644
index 0000000000..ca2b6751e9
--- /dev/null
+++ b/docs/content/docs/cookbook/common-patterns/workflow-composition.mdx
@@ -0,0 +1,118 @@
+---
+title: Workflow Composition
+description: Call workflows from other workflows by direct await (flatten into the parent) or background spawn via start() (separate run).
+type: guide
+summary: Compose workflows two ways — direct await flattens the child into the parent's event log, while background spawn via start() runs the child as an independent run.
+related:
+ - /cookbook/advanced/child-workflows
+ - /docs/api-reference/workflow-api/start
+ - /docs/api-reference/workflow-api/get-run
+---
+
+Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with polling and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows).
+
+## When to use this
+
+- **Direct await** — the parent needs the child's result before continuing, and you want a single unified event log
+- **Background spawn** — the parent doesn't need to wait, and you want the child to be observable as a separate run with its own `runId`
+
+## Pattern
+
+### Direct await (flattening)
+
+Call a child workflow with `await` and the child's steps execute inline within the parent — they appear in the parent's event log as if you'd called them directly.
+
+```typescript lineNumbers
+declare function sendEmail(userId: string): Promise; // @setup
+declare function sendPushNotification(userId: string): Promise; // @setup
+declare function createAccount(userId: string): Promise; // @setup
+declare function setupPreferences(userId: string): Promise; // @setup
+
+// Child workflow
+export async function sendNotifications(userId: string) {
+ "use workflow";
+
+ await sendEmail(userId);
+ await sendPushNotification(userId);
+ return { notified: true };
+}
+
+// Parent workflow calls the child directly
+export async function onboardUser(userId: string) {
+ "use workflow";
+
+ await createAccount(userId);
+ await sendNotifications(userId); // [!code highlight]
+ await setupPreferences(userId);
+
+ return { userId, status: "onboarded" };
+}
+```
+
+The parent waits for the child to finish before continuing. Both functions share a single workflow run, a single retry boundary, and a single event log.
+
+### Background spawn via `start()`
+
+To run a child workflow independently without blocking the parent, call [`start()`](/docs/api-reference/workflow-api/start) from a step. This launches the child as a separate workflow run with its own `runId`.
+
+```typescript lineNumbers
+import { start } from "workflow/api";
+
+declare function generateReport(reportId: string): Promise; // @setup
+declare function fulfillOrder(orderId: string): Promise<{ id: string }>; // @setup
+declare function sendConfirmation(orderId: string): Promise; // @setup
+
+async function triggerReportGeneration(reportId: string) {
+ "use step"; // [!code highlight]
+
+ const run = await start(generateReport, [reportId]); // [!code highlight]
+ return run.runId;
+}
+
+export async function processOrder(orderId: string) {
+ "use workflow";
+
+ const order = await fulfillOrder(orderId);
+
+ const reportRunId = await triggerReportGeneration(orderId); // [!code highlight]
+
+ await sendConfirmation(orderId);
+
+ return { orderId, reportRunId };
+}
+```
+
+The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId` (e.g., via [`getRun()`](/docs/api-reference/workflow-api/get-run)).
+
+
+If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. This is currently a Vercel-specific feature. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
+
+
+## How it works
+
+1. **Direct await flattens.** When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline — the child's steps emit into the parent's event log and share the parent's run ID.
+2. **`start()` mints a new run.** The child gets its own `runId`, its own event log, and its own retry boundary. The parent only sees the `runId` returned by `start()`.
+3. **`start()` must be called from a step.** Calling `start()` directly from a workflow function is not allowed — wrap it in a `"use step"` function. This keeps the spawn deterministic across replays.
+
+## Choosing between the two modes
+
+| | Direct await | Background spawn (`start()`) |
+| --- | --- | --- |
+| Parent waits for child | Yes | No |
+| Has its own `runId` | No (shares parent's) | Yes |
+| Has its own event log | No | Yes |
+| Has its own retry boundary | No | Yes |
+| Best for | Sequential composition, helper workflows | Independent work, fire-and-forget, fan-out |
+
+## Adapting to your use case
+
+- **Spawn many children at once** — call `start()` in a loop inside a step. For more advanced fan-out (chunking, polling, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe.
+- **Wait for a background child to finish** — combine `start()` with `getRun()` polling. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the full polling loop.
+- **Pass results back from background children** — the spawn step returns the `runId`; later, a poll step uses `getRun(runId).returnValue` to fetch the final result.
+
+## Key APIs
+
+- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function
+- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
+- [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run
+- [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value
diff --git a/docs/content/docs/cookbook/index.mdx b/docs/content/docs/cookbook/index.mdx
index 5624a042ec..94cb6d9d18 100644
--- a/docs/content/docs/cookbook/index.mdx
+++ b/docs/content/docs/cookbook/index.mdx
@@ -6,37 +6,33 @@ type: overview
A curated collection of workflow patterns with clean, copy-paste code examples for real use cases.
+## Agent Patterns
+
+- [**Durable Agent**](/cookbook/agent-patterns/durable-agent) — Replace a stateless AI agent with one that survives crashes and retries tool calls
+- [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop) — Pause an agent for human approval, then resume based on the decision
+- [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation) — Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race`
+
## Common Patterns
+- [**Sequential & Parallel Execution**](/cookbook/common-patterns/sequential-and-parallel) — Compose steps with `await`, `Promise.all`, and `Promise.race` against durable sleeps and webhooks
+- [**Workflow Composition**](/cookbook/common-patterns/workflow-composition) — Call workflows from other workflows by direct await or background spawn via `start()`
- [**Saga**](/cookbook/common-patterns/saga) — Coordinate multi-step transactions with automatic rollback when a step fails
- [**Batching**](/cookbook/common-patterns/batching) — Process large collections in parallel batches with failure isolation
- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff
-- [**Fan-Out**](/cookbook/common-patterns/fan-out) — Send to multiple channels in parallel with independent failure handling
- [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead
+- [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep
- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects happen exactly once, even when steps retry
- [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably
-- [**Conditional Routing**](/cookbook/common-patterns/content-router) — Route payloads to different step handlers based on content
-- [**Child Workflows**](/cookbook/common-patterns/child-workflows) — Spawn and orchestrate child workflows from a parent
-- [**Distributed Abort Controller**](/cookbook/common-patterns/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks
-
-## Agent Patterns
-
-- [**Durable Agent**](/cookbook/agent-patterns/durable-agent) — Replace a stateless AI agent with one that survives crashes and retries tool calls
-- [**Tool Streaming**](/cookbook/agent-patterns/tool-streaming) — Stream real-time progress updates from tools to the UI
-- [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop) — Pause an agent for human approval, then resume based on the decision
-- [**Tool Orchestration**](/cookbook/agent-patterns/tool-orchestration) — Choose between step-level and workflow-level tools, or combine both
-- [**Stop Workflow**](/cookbook/agent-patterns/stop-workflow) — Gracefully cancel a running agent workflow using a hook signal
## Integrations
-- [**AI SDK**](/cookbook/integrations/ai-sdk) — Use AI SDK model providers, tool calling, and streaming inside durable workflows
+- [**AI SDK**](/cookbook/integrations/ai-sdk) — Use streamText() directly inside a workflow for lower-level control over model calls and tool execution
- [**Chat SDK**](/cookbook/integrations/chat-sdk) — Build durable chat sessions with workflow persistence and AI SDK chat primitives
- [**Sandbox**](/cookbook/integrations/sandbox) — Orchestrate Vercel Sandbox lifecycle inside durable workflows
## Advanced
-- [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable objects so they cross the workflow boundary
-- [**Durable Objects**](/cookbook/advanced/durable-objects) — Model long-lived stateful entities as workflows
-- [**Isomorphic Packages**](/cookbook/advanced/isomorphic-packages) — Publish packages that work inside and outside the workflow runtime
-- [**Custom Serialization**](/cookbook/advanced/custom-serialization) — Make custom classes survive workflow serialization
+- [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent
+- [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks
+- [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary
- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions
diff --git a/docs/content/docs/cookbook/integrations/ai-sdk.mdx b/docs/content/docs/cookbook/integrations/ai-sdk.mdx
index 74cead294d..7f5c41e49e 100644
--- a/docs/content/docs/cookbook/integrations/ai-sdk.mdx
+++ b/docs/content/docs/cookbook/integrations/ai-sdk.mdx
@@ -1,204 +1,360 @@
---
title: AI SDK
-description: Use AI SDK model providers, tool calling, and streaming inside durable workflows.
+description: Use AI SDK's streamText directly inside durable workflows for lower-level control over model calls and tool execution.
type: guide
-summary: Turn any AI SDK model call into a retryable, observable workflow step with built-in streaming.
+summary: Use streamText() inside a workflow for full control over model options, stop conditions, and output schemas — while tools remain durable steps.
related:
- /docs/ai
+ - /docs/ai/chat-session-modeling
- /docs/ai/defining-tools
- /docs/ai/resumable-streams
- /docs/api-reference/workflow-ai/durable-agent
---
-Workflow SDK integrates with [AI SDK](https://ai-sdk.dev) through the `@workflow/ai` package. This turns your LLM calls and tool executions into durable, retryable steps with built-in streaming and observability.
+[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making those calls durable: the model request, the tool loop, and the multi-turn conversation all survive restarts and timeouts.
-## What It Enables
+For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points.
-- **Durable LLM calls** -- Model invocations become steps that survive crashes and cold starts
-- **Any model provider** -- Use OpenAI, Anthropic, Google, Bedrock, or any AI SDK-compatible provider through [Vercel Gateway](https://vercel.com/docs/gateway) or direct provider configuration
-- **Tool durability** -- Tool executions become steps with automatic retries and event logging
-- **Resumable streaming** -- Clients reconnect mid-stream without losing data
+
+For most agent use cases, prefer [`DurableAgent`](/cookbook/agent-patterns/durable-agent) which wraps [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) and manages the tool loop automatically. This page covers using `streamText()` directly when you need lower-level control.
+
-## When to Use
+## When to use streamText directly
-Use this integration when your application calls an LLM and needs:
+Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `DurableAgent` when you need:
-- Reliability for long-running agent loops (multi-step tool calling)
-- Automatic retry on transient model API failures
-- Stream resumption after disconnects
-- Observability into each model call and tool execution
+* **Custom stop conditions** — [`stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions), [`prepareStep`](https://ai-sdk.dev/docs/ai-sdk-core/agents#prepare-step), or [`onStepFinish`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#on-step-finish) callbacks
+* **Structured output** — [`Output.object()`](https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data) or `Output.array()` alongside tool calling
+* **Step-level callbacks** — `onStepFinish` for logging, metrics, or branching logic
+* **Provider options** — per-step model switching, reasoning budgets, or custom [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options)
-## DurableAgent with Model Providers
+## Multi-turn pattern
-The `DurableAgent` wraps AI SDK's streaming interface. Pass any model string supported by [Vercel Gateway](https://vercel.com/docs/gateway) or a provider-specific model ID.
+One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run.
-```typescript title="workflows/research.ts" lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
-import { getWritable } from "workflow";
-import z from "zod/v4";
+
-async function searchWeb(input: { query: string }): Promise<{ results: string[] }> {
+
+
+```typescript title="workflows/support.ts" lineNumbers
+import { streamText, stepCountIs } from "ai";
+import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
+import type { ModelMessage, UIMessageChunk } from "ai";
+import { z } from "zod";
+
+const MAX_TURNS = 20;
+
+export const turnHook = defineHook({ // [!code highlight]
+ schema: z.object({ message: z.string() }),
+});
+
+async function lookupOrder({ orderId }: { orderId: string }) {
"use step";
- const response = await fetch(
- `https://api.example.com/search?q=${encodeURIComponent(input.query)}`
- );
- const data = await response.json();
- return { results: data.items.map((item: { title: string }) => item.title) };
+ const res = await fetch(`https://api.store.com/orders/${orderId}`);
+ return res.json();
}
-async function summarize(input: { text: string }): Promise<{ summary: string }> {
+async function processRefund({ orderId, reason }: { orderId: string; reason: string }) {
"use step";
- // Each step is individually retried on failure
- const response = await fetch("https://api.example.com/summarize", {
+ const res = await fetch("https://api.store.com/refunds", {
method: "POST",
- body: JSON.stringify({ text: input.text }),
+ body: JSON.stringify({ orderId, reason }),
});
- const data = await response.json();
- return { summary: data.summary };
+ return res.json();
}
-export async function researchAgent(messages: UIMessage[]) {
+const TOOLS = {
+ lookupOrder: {
+ description: "Look up an order by ID",
+ inputSchema: z.object({ orderId: z.string() }),
+ execute: lookupOrder,
+ },
+ processRefund: {
+ description: "Process a refund",
+ inputSchema: z.object({ orderId: z.string(), reason: z.string() }),
+ execute: processRefund,
+ },
+};
+
+// Per-turn step — streams one agent response to the durable writable // [!code highlight]
+async function runTurn(messages: ModelMessage[]) {
+ "use step";
+
+ const result = streamText({
+ model: "anthropic/claude-haiku-4.5",
+ system: "You are a customer support agent.",
+ messages,
+ tools: TOOLS,
+ stopWhen: stepCountIs(8),
+ });
+
+ const writable = getWritable();
+ // preventClose keeps the durable writable open so the next turn can // write to it. Each turn still emits its own start + finish chunks.
+ await result.toUIMessageStream().pipeTo(writable, { preventClose: true }); // [!code highlight]
+
+ const response = await result.response;
+ return { responseMessages: response.messages };
+}
+
+export async function supportWorkflow(initialMessages: ModelMessage[]) {
"use workflow";
- const agent = new DurableAgent({ // [!code highlight]
- model: "anthropic/claude-sonnet-4-20250514",
- instructions: "You are a research assistant. Search the web and summarize findings.",
- tools: {
- searchWeb: {
- description: "Search the web for information",
- inputSchema: z.object({
- query: z.string().describe("The search query"),
- }),
- execute: searchWeb,
- },
- summarize: {
- description: "Summarize a block of text",
- inputSchema: z.object({
- text: z.string().describe("The text to summarize"),
- }),
- execute: summarize,
- },
+ const { workflowRunId } = getWorkflowMetadata();
+ // Create the hook once, outside the loop — same token = HookConflictError // [!code highlight]
+ const hook = turnHook.create({ token: workflowRunId }); // [!code highlight]
+ let allMessages = initialMessages;
+
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
+ const { responseMessages } = await runTurn(allMessages);
+ allMessages = [...allMessages, ...responseMessages];
+
+ const { message } = await hook; // [!code highlight] suspend until next user message
+ if (message === "/done") break;
+
+ allMessages = [...allMessages, { role: "user", content: message }];
+ }
+
+ return { turns: MAX_TURNS };
+}
+```
+
+
+
+
+
+One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
+
+```typescript title="app/api/support/route.ts" lineNumbers
+import type { UIMessage, UIMessageChunk } from "ai";
+import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
+import { start, getRun } from "workflow/api";
+import { supportWorkflow, turnHook } from "@/workflows/support";
+
+// Pump the durable stream until this turn's `finish` chunk, then close // the HTTP response. The source reader is released (not cancelled) so the
+// workflow's durable stream keeps flowing for the next turn.
+function sliceUntilFinish( // [!code highlight]
+ source: ReadableStream
+): ReadableStream {
+ return new ReadableStream({
+ async start(controller) {
+ const reader = source.getReader();
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ controller.enqueue(value);
+ if (value.type === "finish") break; // [!code highlight]
+ }
+ controller.close();
+ } catch (e) {
+ controller.error(e);
+ } finally {
+ reader.releaseLock();
+ }
},
});
+}
- const result = await agent.stream({ // [!code highlight]
- messages: await convertToModelMessages(messages),
- writable: getWritable(),
+// `/done` exits the workflow without emitting chunks. Return a synthetic
+// start+finish so useChat's lifecycle terminates cleanly.
+function emptyTurnStream(): ReadableStream {
+ return new ReadableStream({
+ start(controller) {
+ controller.enqueue({ type: "start", messageId: crypto.randomUUID() });
+ controller.enqueue({ type: "finish" });
+ controller.close();
+ },
});
+}
- return { messages: result.messages };
+export async function POST(req: Request) {
+ const { messages, runId }: { messages: UIMessage[]; runId?: string } =
+ await req.json();
+ const modelMessages = await convertToModelMessages(messages);
+
+ // Follow-up turn: resume hook, return stream starting AFTER the last turn // [!code highlight]
+ if (runId) {
+ try {
+ const run = getRun(runId);
+
+ // Snapshot tail before resuming so our slice only contains this turn // [!code highlight]
+ const probe = run.getReadable();
+ const tailIndex = await probe.getTailIndex();
+ await probe.cancel();
+
+ const lastUser = modelMessages.filter((m) => m.role === "user").at(-1);
+ const text =
+ typeof lastUser?.content === "string"
+ ? lastUser.content
+ : Array.isArray(lastUser?.content)
+ ? lastUser.content
+ .filter((p): p is { type: "text"; text: string } =>
+ "type" in p && p.type === "text"
+ )
+ .map((p) => p.text)
+ .join("")
+ : "";
+
+ await turnHook.resume(runId, { message: text }); // [!code highlight]
+
+ if (text === "/done") {
+ return createUIMessageStreamResponse({
+ stream: emptyTurnStream(),
+ headers: { "x-workflow-run-id": runId },
+ });
+ }
+
+ const stream = sliceUntilFinish(
+ run.getReadable({ startIndex: tailIndex + 1 }) // [!code highlight]
+ );
+
+ return createUIMessageStreamResponse({
+ stream,
+ headers: { "x-workflow-run-id": runId },
+ });
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message.toLowerCase() : "";
+ if (!msg.includes("not found") && !msg.includes("expired")) throw e;
+ // Stale runId — fall through to start fresh
+ }
+ }
+
+ // First turn: start a new workflow // [!code highlight]
+ const run = await start(supportWorkflow, [modelMessages]);
+ const stream = sliceUntilFinish(run.readable);
+
+ return createUIMessageStreamResponse({
+ stream,
+ headers: { "x-workflow-run-id": run.runId },
+ });
}
```
-### Using Different Providers
+
-#### Vercel Gateway (string model IDs)
+
-All string model IDs route through [Vercel Gateway](https://vercel.com/docs/gateway). Switch providers by changing the model string -- no other code changes required.
+Store the `runId` in a ref and pass it in the body of every follow-up. `WorkflowChatTransport` forwards it for you.
-{/* @skip-typecheck - illustrative snippets with intentional redeclarations */}
-```typescript
-// All string model IDs route through Vercel Gateway
-const agent = new DurableAgent({ model: "anthropic/claude-sonnet-4-20250514" });
-const agent = new DurableAgent({ model: "openai/gpt-4o" });
-const agent = new DurableAgent({ model: "google/gemini-2.5-pro" });
-const agent = new DurableAgent({ model: "bedrock/claude-haiku-4-5-20251001-v1" });
+```tsx title="components/support-chat.tsx" lineNumbers
+"use client";
+
+import { useChat } from "@ai-sdk/react";
+import { WorkflowChatTransport } from "@workflow/ai";
+import { useMemo, useRef, useState } from "react";
+
+export function SupportChat() {
+ const [input, setInput] = useState("");
+ const runIdRef = useRef(null); // [!code highlight]
+
+ const transport = useMemo(
+ () =>
+ new WorkflowChatTransport({
+ api: "/api/support",
+ prepareSendMessagesRequest: ({ messages, body }) => ({
+ body: { ...body, messages, runId: runIdRef.current }, // [!code highlight]
+ }),
+ onChatSendMessage: (response) => {
+ const id = response.headers.get("x-workflow-run-id");
+ if (id) runIdRef.current = id; // [!code highlight]
+ },
+ }),
+ []
+ );
+
+ const { messages, sendMessage, status } = useChat({ transport });
+ const busy = status === "streaming" || status === "submitted";
+
+ return (
+
+ );
+}
```
-#### Direct Provider Access
+
-Import from a provider package to bypass Gateway and connect to the provider directly.
+
-```typescript
-import { DurableAgent } from "@workflow/ai/agent";
-import { openai } from "@workflow/ai/openai";
+## How it works
-const agent = new DurableAgent({ model: openai("gpt-4o") });
-```
+1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns.
+2. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`.
+3. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it.
+4. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing.
+5. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns.
+6. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming".
+
+## Pitfalls
-### Provider-Specific Options
+Non-obvious correctness details worth knowing before adapting this pattern.
-Pass provider options for features like reasoning or extended thinking.
+### Snapshot `tailIndex` *before* resuming the hook
+{/* @skip-typecheck - fragment referencing variables from the surrounding multi-turn pattern */}
```typescript
-const agent = new DurableAgent({
- model: "anthropic/claude-sonnet-4-20250514",
- providerOptions: {
- anthropic: { thinking: { type: "enabled", budgetTokens: 10000 } },
- },
- // ...tools and instructions
-});
+const tailIndex = await probe.getTailIndex(); // [!code highlight] FIRST
+await probe.cancel();
+await turnHook.resume(runId, { message: text }); // [!code highlight] THEN
+const stream = run.getReadable({ startIndex: tailIndex + 1 });
```
-## Tool Functions with Steps
+Reversing the order races the workflow: by the time you read `tailIndex`, the next turn has already written its `start` chunk, and your `startIndex + 1` skips past it.
-Tool `execute` functions can optionally include steps by using the `"use step"` directive. When a tool is **not** a step, it runs inside the workflow context and can modify workflow state directly. When a tool **is** marked with `"use step"`, it becomes a durable step with:
+### Don't call `writable.close()` inside a workflow function
-- **Automatic retries** -- If a tool fails (network error, API timeout), the framework retries it
-- **Event logging** -- Inputs and outputs are recorded for observability and replay
-- **Idempotency** -- On replay after a crash, completed steps return their cached result
+I/O operations like closing streams must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. When the workflow returns, the runtime closes the underlying writable for you.
-```typescript
-async function bookFlight(input: {
- origin: string;
- destination: string;
- date: string;
-}): Promise<{ confirmationId: string }> {
- "use step";
- // This call is retried on transient failures and its result is persisted
- const response = await fetch("https://api.airline.com/book", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(input),
- });
- if (!response.ok) throw new Error(`Booking failed: ${response.status}`);
- return response.json();
-}
-```
+### Don't use `TransformStream.terminate()` to slice the stream
-## Resumable Streaming
+A `TransformStream` with `controller.terminate()` on the `finish` chunk seems like the obvious fit for `sliceUntilFinish`, but throws `Invalid state: TransformStream has been terminated` when late-arriving chunks hit the transform callback. Manual pumping through a custom `ReadableStream` (as shown above) sidesteps the problem entirely.
-Use `WorkflowChatTransport` on the client to automatically reconnect to a workflow's stream if the connection drops.
+### Release the source reader, don't cancel it
-```typescript title="app/api/chat/route.ts" lineNumbers
-import { createUIMessageStreamResponse } from "ai";
-import { start } from "workflow/api";
-import { researchAgent } from "@/workflows/research";
+In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Cancelling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock just detaches our reader; the durable stream keeps flowing.
-export async function POST(request: Request) {
- const { messages } = await request.json();
- const run = await start(researchAgent, [messages]); // [!code highlight]
+### Handle stale `runId` gracefully
- return createUIMessageStreamResponse({
- stream: run.readable, // [!code highlight]
- headers: { "x-workflow-run-id": run.runId },
- });
-}
-```
+Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.
-```typescript title="components/chat.tsx" lineNumbers
-"use client";
+## streamText vs DurableAgent
-import { useChat } from "@ai-sdk/react";
-import { WorkflowChatTransport } from "@workflow/ai";
+| | `streamText()` | `DurableAgent` |
+|---|---|---|
+| **Tool loop** | AI SDK handles via `stopWhen` | DurableAgent handles internally |
+| **LLM call durability** | Re-executes on replay | Each LLM call is a durable step |
+| **Stop conditions** | `stopWhen`, `prepareStep` | `prepareStep` only |
+| **Structured output** | `Output.object()`, `Output.array()` | Not available |
+| **Step callbacks** | `onStepFinish`, `onChunk` | Not available |
+| **Setup** | Manual stream piping | Automatic |
-export function Chat() {
- const chat = useChat({
- transport: new WorkflowChatTransport({ // [!code highlight]
- api: "/api/chat",
- }),
- });
+Use `DurableAgent` for most agent use cases. Use `streamText` when you need the additional control.
- // Standard useChat usage -- reconnection is handled automatically
- return (
-
- {chat.messages.map((m) => (
-
{m.content}
- ))}
-
- );
-}
-```
+## Key APIs
+
+**AI SDK** ([docs](https://ai-sdk.dev/docs))
+
+* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable
+* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools wrap `"use step"` functions so each tool call is replayed from the log, not re-executed
+* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn
+* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary
+* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client
+
+**Workflow SDK**
-See [Resumable Streams](/docs/ai/resumable-streams) for advanced options like `startIndex` and `prepareReconnectToStreamRequest`.
+* [`"use step"`](/docs/api-reference/workflow/use-step) — makes tool executions durable
+* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
+* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
+* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
+* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns
diff --git a/docs/content/docs/cookbook/integrations/chat-sdk.mdx b/docs/content/docs/cookbook/integrations/chat-sdk.mdx
index b564821c78..42128a7a17 100644
--- a/docs/content/docs/cookbook/integrations/chat-sdk.mdx
+++ b/docs/content/docs/cookbook/integrations/chat-sdk.mdx
@@ -1,203 +1,303 @@
---
title: Chat SDK
-description: Build durable chat sessions by combining workflow persistence with AI SDK's chat primitives.
+description: Make Chat SDK bot sessions durable — one workflow run per conversation thread, with hooks bridging inbound platform events into long-running agent logic.
type: guide
-summary: Use workflow hooks and streaming to create chat sessions that survive disconnects and server restarts.
+summary: Chat SDK normalizes Slack, Teams, Discord, Telegram and friends into one thread/message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts.
related:
- - /docs/ai/chat-session-modeling
- - /docs/ai/resumable-streams
- - /docs/ai/message-queueing
- - /docs/api-reference/workflow-ai/durable-agent
+ - /docs/cookbook/integrations/ai-sdk
+ - /docs/cookbook/integrations/sandbox
- /docs/api-reference/workflow/define-hook
+ - /docs/api-reference/workflow-api/start
+ - /docs/api-reference/workflow-api/get-run
---
-AI SDK provides chat primitives (`useChat`, message types, streaming utilities) for building chat interfaces. Workflow SDK makes those chat sessions durable -- surviving disconnects, cold starts, and server restarts -- by persisting every message and LLM response as workflow events.
+[Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write the bot once, deploy to every platform. It handles webhook verification, event normalization, subscriptions, and cross-platform features like cards and modals.
-## What It Enables
+Workflow SDK complements it by making bot **sessions** durable. Each conversation thread maps to a long-running workflow run that:
-- **Durable chat history** -- Messages and responses are persisted in the workflow event log, not just client state
-- **Resumable sessions** -- Users reconnect and pick up where they left off, even after server restarts
-- **Multi-turn conversations** -- A single workflow manages an entire chat session with hook-based message injection
-- **Server-side message queueing** -- Inject follow-up messages while the agent is still processing
+- Owns multi-turn state in the durable event log instead of Redis-by-hand bookkeeping
+- Can `sleep()` for hours or days waiting for a user reply, an approval, or a scheduled follow-up
+- Survives deploys, cold starts, and crashes — the session picks up from the last step on replay
+- Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running
-## When to Use
+The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev.
-Use this pattern when your chat application needs:
+## How It Fits Together
-- Persistence beyond the browser session
-- Recovery from server failures mid-conversation
-- Long-running agent sessions (minutes to hours)
-- Server-driven message injection (system messages, external events)
+Chat SDK owns the edge — webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session — state, loops, sleeps, retries. They meet at exactly two points:
-## Single-Turn: Stateless Sessions
+```mermaid
+flowchart TD
+ A["Platform webhook"] --> B["Chat SDK event handler (onNewMention, onSubscribedMessage, …)"]
+ B -->|"no runId in thread state"| C["start(durableChatSession, …)"]
+ B -->|"runId in thread state"| D["resumeHook(runId, { message })"]
+ C --> E["Workflow run (durable) one per thread; suspends between turns"]
+ D --> E
+ E --> F[""use step" helpers thread.post(), thread.subscribe(), thread.setState(), …"]
+```
-Each user message starts a new workflow run. The client owns the message history and sends the full array with each request. This is the simplest pattern.
+- **Inbound** — Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter).
+- **Outbound** — the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file — adapter packages use Node-only modules that aren't available in the workflow sandbox.
-```typescript title="workflows/chat.ts" lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
-import { getWritable } from "workflow";
+## Why Workflow + Chat SDK
-export async function chat(messages: UIMessage[]) {
- "use workflow";
+Without Workflow, a long-running bot session usually means one of:
+- Holding a webhook request open while the agent runs (doesn't survive restarts, blows past platform timeouts)
+- Writing session state to Redis manually, plus a scheduler for timeouts and retries, plus custom reconnection logic
- const agent = new DurableAgent({
- model: "anthropic/claude-sonnet-4-20250514",
- instructions: "You are a helpful assistant.",
- tools: { /* your tools here */ },
- });
+Workflow replaces all of that with a single durable function. The bot can:
- const result = await agent.stream({ // [!code highlight]
- messages: await convertToModelMessages(messages),
- writable: getWritable(),
- });
+- Run a tool loop for minutes while the user watches typing indicators
+- Wait for a human approval in another thread before continuing
+- Schedule a follow-up message 24 hours later via `sleep("24h")`
+- Pause on sandbox snapshot, resume when the user sends the next command (see the [Sandbox integration](/docs/cookbook/integrations/sandbox))
- return { messages: result.messages };
-}
-```
+Because the session *is* a workflow run, its history is recoverable from the event log — no separate message store to keep in sync.
-```typescript title="app/api/chat/route.ts" lineNumbers
-import { createUIMessageStreamResponse } from "ai";
-import { start } from "workflow/api";
-import { chat } from "@/workflows/chat";
+## The Pattern: One Thread = One Workflow Run
-export async function POST(request: Request) {
- const { messages } = await request.json();
- const run = await start(chat, [messages]); // [!code highlight]
+Three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox.
- return createUIMessageStreamResponse({
- stream: run.readable,
- headers: { "x-workflow-run-id": run.runId },
- });
-}
-```
+
+
+
+
+Register the `Chat` instance as a singleton so step functions can dynamically import it and resolve adapters + state:
-The client uses `WorkflowChatTransport` for automatic stream resumption.
-
-```typescript title="components/chat.tsx" lineNumbers
-"use client";
-
-import { useChat } from "@ai-sdk/react";
-import { WorkflowChatTransport } from "@workflow/ai";
-
-export function Chat() {
- const chat = useChat({
- transport: new WorkflowChatTransport({ api: "/api/chat" }), // [!code highlight]
- });
-
- return (
-
- {chat.messages.map((m) => (
-
{m.content}
- ))}
-
-
- );
+```typescript title="lib/bot.ts" lineNumbers
+import { Chat } from "chat";
+import { createSlackAdapter } from "@chat-adapter/slack";
+import { createRedisState } from "@chat-adapter/state-redis";
+
+const adapters = {
+ slack: createSlackAdapter(),
+};
+
+export interface ThreadState {
+ runId?: string; // [!code highlight]
}
+
+export const bot = new Chat({
+ userName: "durable-bot",
+ adapters,
+ state: createRedisState(),
+ dedupeTtlMs: 600_000,
+}).registerSingleton(); // [!code highlight]
```
-## Multi-Turn: Durable Sessions
+`registerSingleton()` is important: Chat SDK re-hydrates `Thread` objects inside step functions, and it needs a registered singleton to resolve adapters and state for those rehydrated instances.
-A single workflow manages the entire conversation. The workflow loops, waiting for new messages via a hook. This gives you server-side ownership of the full chat history.
+
-```typescript title="workflows/durable-chat.ts" lineNumbers
-import { DurableAgent } from "@workflow/ai/agent";
-import {
- convertToModelMessages,
- type UIMessage,
- type UIMessageChunk,
-} from "ai";
-import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
-import { z } from "zod";
+
-const chatMessageHook = defineHook({ // [!code highlight]
- schema: z.object({
- messages: z.array(z.any()),
- }),
-});
+The workflow is a plain loop over a hook. It receives the serialized thread + first message from the handler, revives them via Chat SDK's standalone `reviver`, and every platform-side effect goes inside a `"use step"` helper:
+
+```typescript title="workflows/durable-chat-session.ts" lineNumbers
+import { Message, reviver, type Thread } from "chat";
+import { defineHook, getWorkflowMetadata } from "workflow";
+import type { ThreadState } from "@/lib/bot";
+
+// Hook payload lives in its own file so the webhook side can import it without
+// pulling in the workflow module.
+import type { ChatTurnPayload } from "@/workflows/chat-turn-hook";
+
+const chatTurnHook = defineHook(); // [!code highlight]
+
+async function postAssistantMessage(
+ thread: Thread,
+ text: string
+) {
+ "use step";
+ // Dynamic import keeps adapter packages out of the workflow sandbox.
+ const { bot } = await import("@/lib/bot"); // [!code highlight]
+ await bot.initialize();
+ await thread.post(text);
+}
+
+async function runTurn(text: string) {
+ "use step";
+ // Your AI SDK call, database lookup, tool loop, etc.
+ return `You said: ${text}`;
+}
+
+async function handleMessage(
+ thread: Thread,
+ message: Message
+) {
+ const text = message.text.trim();
+ if (text.toLowerCase() === "done") return false;
-export async function durableChat(initialMessages: UIMessage[]) {
+ const reply = await runTurn(text);
+ await postAssistantMessage(thread, reply);
+ return true;
+}
+
+export async function durableChatSession(payload: string) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
- let allMessages = await convertToModelMessages(initialMessages);
-
- const agent = new DurableAgent({
- model: "anthropic/claude-sonnet-4-20250514",
- instructions: "You are a helpful assistant.",
- tools: { /* your tools here */ },
- });
-
- // First turn
- const firstResult = await agent.stream({
- messages: allMessages,
- writable: getWritable(),
- preventClose: true,
- });
- allMessages = firstResult.messages;
-
- // Subsequent turns -- wait for new messages via hook
+ const { thread, message } = JSON.parse(payload, reviver) as { // [!code highlight]
+ thread: Thread;
+ message: Message;
+ };
+
+ const hook = chatTurnHook.create({ token: workflowRunId });
+
+ await postAssistantMessage(thread, "Session started. Reply here; send `done` to stop.");
+
+ if (!(await handleMessage(thread, message))) return;
+
+ // Each hook resumption is one turn. The workflow stays suspended between
+ // messages — zero compute cost while idle.
while (true) {
- const hook = chatMessageHook.create({ token: workflowRunId });
- const { messages: newMessages } = await hook; // [!code highlight]
-
- allMessages = [
- ...allMessages,
- ...await convertToModelMessages(newMessages),
- ];
-
- const result = await agent.stream({
- messages: allMessages,
- writable: getWritable(),
- preventClose: true,
- });
- allMessages = result.messages;
+ const { message: nextRaw } = await hook; // [!code highlight]
+ const next = Message.fromJSON(nextRaw);
+ if (!(await handleMessage(thread, next))) return;
}
}
```
-### Multi-Turn API Routes
+```typescript title="workflows/chat-turn-hook.ts" lineNumbers
+import type { SerializedMessage } from "chat";
+
+export type ChatTurnPayload = {
+ message: SerializedMessage;
+};
+```
+
+
-You need two routes: one to start the session, another to send follow-up messages.
+
-```typescript title="app/api/chat/route.ts" lineNumbers
-import { createUIMessageStreamResponse } from "ai";
-import { start } from "workflow/api";
-import { durableChat } from "@/workflows/durable-chat";
+Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state:
-export async function POST(request: Request) {
- const { messages } = await request.json();
- const run = await start(durableChat, [messages]); // [!code highlight]
+```typescript title="lib/chat-session-handlers.ts" lineNumbers
+import type { Message, Thread } from "chat";
+import { getRun, resumeHook, start } from "workflow/api";
+import { bot, type ThreadState } from "@/lib/bot";
+import { durableChatSession } from "@/workflows/durable-chat-session";
+import type { ChatTurnPayload } from "@/workflows/chat-turn-hook";
- return createUIMessageStreamResponse({
- stream: run.readable,
- headers: { "x-workflow-run-id": run.runId },
- });
+async function startSession(thread: Thread, message: Message) {
+ const run = await start(durableChatSession, [ // [!code highlight]
+ JSON.stringify({
+ thread: thread.toJSON(),
+ message: message.toJSON(),
+ }),
+ ]);
+ await thread.setState({ runId: run.runId });
}
+
+async function routeTurn(thread: Thread, message: Message) {
+ const state = await thread.state;
+
+ // No run yet, or the previous run finished — start fresh.
+ if (!state?.runId || !(await getRun(state.runId).exists)) {
+ await startSession(thread, message);
+ return;
+ }
+
+ try {
+ await resumeHook(state.runId, { // [!code highlight]
+ message: message.toJSON(),
+ });
+ } catch (err) {
+ const msg = err instanceof Error ? err.message.toLowerCase() : "";
+ if (msg.includes("not found") || msg.includes("expired")) {
+ // Stale runId — start a new session rather than dropping the message.
+ await startSession(thread, message);
+ return;
+ }
+ throw err;
+ }
+}
+
+bot.onNewMention(async (thread, message) => {
+ await thread.subscribe();
+ await routeTurn(thread, message);
+});
+
+bot.onSubscribedMessage(async (thread, message) => {
+ await routeTurn(thread, message);
+});
```
-```typescript title="app/api/chat/follow-up/route.ts" lineNumbers
-import { resumeHook } from "workflow/api";
+Wire Chat SDK's webhook handler into a catch-all route. Importing `chat-session-handlers` for side effects registers the event handlers before the first webhook arrives:
+
+```typescript title="app/api/webhooks/[platform]/route.ts" lineNumbers
+import "@/lib/chat-session-handlers";
+import { after } from "next/server";
+import { bot } from "@/lib/bot";
+
+type Platform = keyof typeof bot.webhooks;
+
+export async function POST(
+ req: Request,
+ { params }: { params: Promise<{ platform: string }> }
+) {
+ const { platform } = await params;
+ const handler = bot.webhooks[platform as Platform];
+ if (!handler) return new Response(`Unknown platform: ${platform}`, { status: 404 });
-export async function POST(request: Request) {
- const { runId, messages } = await request.json();
- await resumeHook(runId, { messages }); // [!code highlight]
- return new Response("OK");
+ return handler(req, { waitUntil: (task) => after(() => task) }); // [!code highlight]
}
```
-## Choosing a Pattern
+
+
+
+
+## How It Works
+
+1. **Thread state stores the `runId`.** Chat SDK's state adapter (Redis, Postgres, memory) holds `{ runId }` per thread. That's the only piece of glue between the two SDKs.
+2. **First mention → `start()`.** Handler serializes `thread` + `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, stashes the returned `runId` in thread state.
+3. **Subsequent messages → `resumeHook()`.** Handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow picks up on the next `await hook` iteration.
+4. **Workflow posts back via steps.** All Chat SDK side effects (`thread.post`, `thread.subscribe`, `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox.
+5. **Session ends — two ways.** The workflow returns normally (user said `done`, approval granted, etc.), or the workflow throws. Either way the run completes; the next inbound message with the stale `runId` falls through to `startSession()`.
+
+The workflow is fully durable between turns: `await hook` suspends with zero compute cost, and platform webhooks can fire from anywhere without concern for which server instance handled the previous turn.
+
+## Extending the Pattern
+
+Because the session is just a workflow, everything else from the cookbook composes naturally:
+
+- **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/docs/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()` — Chat SDK handles platform-specific streaming (Slack edit-in-place, Telegram message-per-chunk, etc.).
+- **Give the bot a sandbox.** Combine with the [Sandbox integration](/docs/cookbook/integrations/sandbox): each thread gets its own persistent sandbox session, snapshots on idle, resumes on the next message. That's effectively a coding-agent bot.
+- **Human-in-the-loop approvals.** `Promise.race([hook, approvalHook])` inside the workflow, post buttons in the thread via [cards](https://chat-sdk.dev/docs/cards), resume `approvalHook` from `bot.onAction(...)`.
+- **Scheduled follow-ups.** `sleep("24h")` before a proactive check-in. Surviving restarts is free.
+
+## Pitfalls
+
+### Don't import the bot at the top of workflow files
+
+Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow — it's standalone and has no adapter dependencies.
+
+### Register the bot as a singleton
+
+`new Chat({...}).registerSingleton()`. Chat SDK rehydrates `Thread` objects inside step functions via `reviver`, and it looks up adapters + state from the registered singleton. Without it, thread methods throw when called from step contexts.
+
+### Hook payloads must be JSON-serializable
+
+`Message` and `Thread` have methods, so pass them through `.toJSON()` / `Message.fromJSON()` across the hook boundary. Define a `ChatTurnPayload` type in its own file so both the webhook handler (in the Node bundle) and the workflow (in the workflow sandbox) can share it without dragging in adapter code.
+
+### Handle stale `runId`s
+
+A workflow run ends but its `runId` is still cached in thread state. The next message calls `resumeHook` on a dead run and throws `not found` / `expired`. Gate on `getRun(runId).exists` before resuming, or catch the error and fall through to `startSession`. Either way the user's message must not be dropped.
+
+### Keep the hook outside the loop
+
+One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused every iteration. Creating a new hook with the same token throws `HookConflictError`. This is the same rule as the [AI SDK](/docs/cookbook/integrations/ai-sdk) and [Sandbox](/docs/cookbook/integrations/sandbox) session patterns.
+
+### Platform timeouts are separate from workflow timeouts
-| | Single-Turn | Multi-Turn |
-|---|---|---|
-| **State ownership** | Client | Server (workflow event log) |
-| **Message injection** | Not needed | Via hooks |
-| **Complexity** | Low | Medium |
-| **Session duration** | Per-request | Minutes to hours |
-| **Crash recovery** | Client resends full history | Workflow replays from event log |
+Slack wants a 200 within 3 seconds. The webhook handler returns immediately after `resumeHook` (which is fast) — the workflow then runs in the background and posts back via `thread.post`. Don't try to `await` the whole turn inside the webhook handler; that's what breaks in the naive integration.
-Start with single-turn. Move to multi-turn when you need server-owned state, message injection from external sources, or sessions that outlive the browser tab.
+## Key APIs
-See [Chat Session Modeling](/docs/ai/chat-session-modeling) for the full guide including multiplayer patterns and message queueing.
+- [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message) — Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer.
+- [`start()`](/docs/api-reference/workflow-api/start) — start a new session workflow. Store the returned `runId` in thread state.
+- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — forward a new platform message to the running workflow.
+- [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s.
+- [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow.
+- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions.
diff --git a/docs/content/docs/cookbook/integrations/sandbox.mdx b/docs/content/docs/cookbook/integrations/sandbox.mdx
index 5ef5cfe544..942f110f5b 100644
--- a/docs/content/docs/cookbook/integrations/sandbox.mdx
+++ b/docs/content/docs/cookbook/integrations/sandbox.mdx
@@ -1,128 +1,516 @@
---
title: Sandbox
-description: Orchestrate Vercel Sandbox lifecycle -- creation, code execution, snapshotting -- inside durable workflows.
+description: Model one Vercel Sandbox per workflow run — durable, idle-efficient, and not bound by the 5-hour sandbox hard cap.
type: guide
-summary: Use workflow steps to provision sandboxes, run code, and manage sandbox lifecycle with automatic cleanup on failure.
+summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by runId — so one logical session can run effectively forever.
related:
- /docs/ai/defining-tools
- /docs/foundations/errors-and-retries
- - /docs/api-reference/workflow-ai/durable-agent
+ - /docs/cookbook/common-patterns/scheduling
+ - /docs/cookbook/agent-patterns/durable-agent
---
-[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package implements first-class support for the Workflow SDK -- the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `destroy`, etc.) implicitly run as steps. This means you can interact with sandboxes directly inside workflow functions without wrapping each operation in a separate `"use step"` function.
+[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK — the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function.
-## What It Enables
+## Why Workflow + Sandbox
-- **Durable sandbox sessions** -- Sandbox provisioning and teardown survive cold starts
-- **Automatic cleanup** -- Saga-style compensation ensures sandboxes are destroyed on failure
-- **Multi-step code execution** -- Run a sequence of commands in the same sandbox with each step logged
-- **Agent-driven sandboxes** -- Give your DurableAgent a tool that spins up sandboxes on demand
+A sandbox alone gets you an isolated VM. A workflow around it gets you a **durable controller** for that VM's entire lifetime:
-## When to Use
+- **One workflow run = one sandbox session.** The `runId` is the only state you need to persist on the client. Close the tab, come back a week later, POST the same `runId` and you're back in the same session.
+- **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer — when idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. Next command → spin a new sandbox from the snapshot with filesystem, installed packages, and git history intact.
+- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure.
+- **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy.
-Use this integration when your workflow needs to:
+## Use Case: Coding Agents
-- Execute user-provided or AI-generated code safely
-- Run multi-step build/test pipelines in isolated environments
-- Provision temporary environments for interactive sessions
-- Snapshot sandbox state between steps for reproducibility
+This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return.
-## Sandbox Lifecycle in a Workflow
+Most coding-agent workloads look like this:
-Because `@vercel/sandbox` methods are implicit steps, each call is automatically persisted to the event log. If a failure occurs partway through, the workflow replays from where it left off.
+- User sends a task → agent plans, reads files, runs shell commands, commits.
+- User walks away mid-run → agent keeps going, eventually goes idle waiting for input.
+- User comes back days later → same branch, same filesystem, same conversation history.
+
+Without durable workflows you'd need a separate state store for the agent loop, a separate job queue for retries, a separate scheduler for idle cleanup, and bespoke reconnection logic. With the pattern below, all of it is one file.
+
+## Quickstart: One-shot Pipeline
+
+Before the full session pattern, the simplest shape. Each sandbox method is an implicit step, so the event log records every command and the workflow replays from the last completed call on restart.
```typescript title="workflows/sandbox-pipeline.ts" lineNumbers
import { Sandbox } from "@vercel/sandbox";
-export async function sandboxPipeline(input: {
- template: string;
- commands: string[];
-}) {
+export async function sandboxPipeline(input: { commands: string[] }) {
"use workflow";
- const sandbox = await Sandbox.create({ template: input.template }); // [!code highlight]
+ const sandbox = await Sandbox.create({ runtime: "node22" }); // [!code highlight]
try {
const results = [];
for (const command of input.commands) {
- const result = await sandbox.runCommand(command); // [!code highlight]
- results.push(result);
+ const result = await sandbox.runCommand({ // [!code highlight]
+ cmd: "bash",
+ args: ["-c", command],
+ });
+ results.push({
+ command,
+ exitCode: result.exitCode,
+ stdout: await result.stdout(),
+ stderr: await result.stderr(),
+ });
}
return { status: "completed", results };
- } catch (error) {
- await sandbox.destroy(); // [!code highlight]
- throw error;
+ } finally {
+ await sandbox.stop(); // [!code highlight]
}
}
```
-## Sandbox as an Agent Tool
+## Session Pattern: Persistent Sandbox Beyond the Hard Cap
-Give a DurableAgent the ability to create and use sandboxes. The agent decides when to spin up a sandbox, what code to run, and when to tear it down. Since sandbox methods are implicit steps, the tool execute functions can call them directly.
+One workflow run owns a sandbox for its whole lifetime. The workflow's loop does two jobs simultaneously:
-```typescript title="workflows/code-agent.ts" lineNumbers
-import { Sandbox } from "@vercel/sandbox";
-import { DurableAgent } from "@workflow/ai/agent";
-import { convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai";
-import { getWritable } from "workflow";
-import z from "zod/v4";
+1. **Command pipeline** — await a hook, run the next user command, stream output, loop.
+2. **Sandbox lifecycle** — race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap).
+
+When the timer wins:
+
+- **Idle** → `sandbox.snapshot()` and wait indefinitely for the next command. No compute while asleep.
+- **Near sandbox hard cap** → `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session appears continuous; the underlying VM just rotated.
+
+The only way out is an explicit `/destroy` command.
+
+
+
+
+
+```typescript title="workflows/sandbox-session.ts" lineNumbers
+import { defineHook, sleep, getWritable, getWorkflowMetadata } from "workflow";
+import { Sandbox, type Snapshot } from "@vercel/sandbox";
+import { z } from "zod";
+
+export const commandHook = defineHook({ // [!code highlight]
+ schema: z.object({ command: z.string() }),
+});
+
+const RUNTIME = "node22";
+const HIBERNATE_AFTER_MS = 30 * 60_000; // 30 min idle → hibernate
+const SANDBOX_TIMEOUT_MS = 5 * 60 * 60_000; // sandbox hard cap (5h)
+const REFRESH_SAFETY_MS = 5 * 60_000; // refresh 5 min before the cap
-export async function codeAgent(messages: UIMessage[]) {
+export type SandboxEvent =
+ | {
+ type: "created";
+ sandboxId: string;
+ runtime: string;
+ startedAt: number;
+ sandboxExpiresAt: number;
+ hibernateAfterMs: number;
+ }
+ | {
+ type: "status";
+ state:
+ | "active"
+ | "hibernating"
+ | "hibernated"
+ | "resuming"
+ | "refreshing"
+ | "destroyed";
+ at: number;
+ sandboxId?: string;
+ sandboxExpiresAt?: number;
+ snapshotId?: string;
+ }
+ | { type: "activity"; at: number }
+ | { type: "command_start"; id: string; command: string; at: number }
+ | { type: "command_output"; id: string; stream: "stdout" | "stderr"; data: string }
+ | { type: "command_end"; id: string; exitCode: number | null; durationMs: number }
+ | { type: "result"; status: "destroyed"; durationMs: number };
+
+async function emit(event: SandboxEvent) {
+ "use step";
+ const writer = getWritable().getWriter();
+ try {
+ await writer.write(event);
+ } finally {
+ writer.releaseLock();
+ }
+}
+
+async function runCommandAndStream(sandbox: Sandbox, id: string, command: string) {
+ "use step";
+ const writer = getWritable().getWriter();
+ const startedAt = Date.now();
+ try {
+ await writer.write({ type: "command_start", id, command, at: startedAt });
+ const result = await sandbox.runCommand({ cmd: "bash", args: ["-c", command] });
+ const stdout = await result.stdout();
+ if (stdout) await writer.write({ type: "command_output", id, stream: "stdout", data: stdout });
+ const stderr = await result.stderr();
+ if (stderr) await writer.write({ type: "command_output", id, stream: "stderr", data: stderr });
+ await writer.write({
+ type: "command_end", id,
+ exitCode: result.exitCode,
+ durationMs: Date.now() - startedAt,
+ });
+ } finally {
+ writer.releaseLock();
+ }
+}
+
+export async function sandboxSessionWorkflow() {
"use workflow";
- let activeSandbox: Sandbox | null = null;
-
- const agent = new DurableAgent({
- model: "anthropic/claude-sonnet-4-20250514",
- instructions:
- "You are a coding assistant. You can create sandboxes to run code. " +
- "Always create a sandbox first, then execute code in it. " +
- "Clean up the sandbox when you are done.",
- tools: {
- createSandbox: {
- description: "Create an isolated sandbox environment for running code",
- inputSchema: z.object({
- template: z.string().describe("The sandbox template (e.g., 'node', 'python')"),
- }),
- execute: async ({ template }) => {
- activeSandbox = await Sandbox.create({ template }); // [!code highlight]
- return { sandboxId: activeSandbox.id };
- },
- },
- executeCode: {
- description: "Execute a command in the active sandbox",
- inputSchema: z.object({
- command: z.string().describe("The command to execute"),
- }),
- execute: async ({ command }) => {
- if (!activeSandbox) throw new Error("No active sandbox");
- return activeSandbox.runCommand(command); // [!code highlight]
- },
- },
- cleanupSandbox: {
- description: "Destroy the active sandbox when finished",
- inputSchema: z.object({}),
- execute: async () => {
- if (!activeSandbox) throw new Error("No active sandbox");
- await activeSandbox.destroy(); // [!code highlight]
- activeSandbox = null;
- return { cleaned: true };
+ const { workflowRunId } = getWorkflowMetadata();
+ // Create the hook once, outside the loop — reusing the same token from inside // [!code highlight]
+ // the loop would throw HookConflictError. // [!code highlight]
+ const hook = commandHook.create({ token: workflowRunId });
+
+ const startedAt = Date.now();
+
+ let sandbox: Sandbox = await Sandbox.create({
+ runtime: RUNTIME,
+ timeout: SANDBOX_TIMEOUT_MS,
+ });
+ let sandboxCreatedAt = Date.now();
+ let sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
+
+ await emit({
+ type: "created",
+ sandboxId: sandbox.sandboxId,
+ runtime: RUNTIME,
+ startedAt,
+ sandboxExpiresAt,
+ hibernateAfterMs: HIBERNATE_AFTER_MS,
+ });
+ await emit({
+ type: "status", state: "active", at: Date.now(),
+ sandboxId: sandbox.sandboxId, sandboxExpiresAt,
+ });
+
+ let snapshot: Snapshot | null = null;
+ let hibernated = false;
+ let lastActivityAt = startedAt;
+ let counter = 0;
+ let destroyed = false;
+
+ try {
+ while (!destroyed) {
+ if (hibernated && snapshot) {
+ // While hibernated, the VM is already stopped. Just wait for the next
+ // command — no idle timer, no compute cost.
+ const payload = await hook;
+ if (payload.command === "/destroy") { destroyed = true; break; }
+
+ await emit({ type: "status", state: "resuming", at: Date.now() });
+ sandbox = await Sandbox.create({ // [!code highlight]
+ source: { type: "snapshot", snapshotId: snapshot.snapshotId }, // [!code highlight]
+ timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
+ });
+ sandboxCreatedAt = Date.now();
+ sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
+ hibernated = false;
+ snapshot = null;
+ await emit({
+ type: "status", state: "active", at: Date.now(),
+ sandboxId: sandbox.sandboxId, sandboxExpiresAt,
+ });
+
+ counter += 1;
+ await runCommandAndStream(sandbox, `cmd-${counter}`, payload.command);
+ lastActivityAt = Date.now();
+ await emit({ type: "activity", at: lastActivityAt });
+ continue;
+ }
+
+ // Active — wake at whichever comes first: idle-deadline or refresh-deadline.
+ const idleDeadline = lastActivityAt + HIBERNATE_AFTER_MS;
+ const refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS;
+ const wakeAt = Math.min(idleDeadline, refreshDeadline);
+ const sleepMs = Math.max(0, wakeAt - Date.now());
+
+ const outcome = await Promise.race([ // [!code highlight]
+ hook.then((p) => ({ type: "command" as const, command: p.command })),
+ sleep(`${sleepMs}ms`).then(() => ({ type: "timer" as const })),
+ ]);
+
+ if (outcome.type === "timer") {
+ const nearExpiry = Date.now() >= refreshDeadline;
+
+ if (nearExpiry) {
+ // Proactive refresh — snapshot and immediately recreate so the
+ // session outlives the sandbox hard cap.
+ await emit({ type: "status", state: "refreshing", at: Date.now() });
+ const snap = await sandbox.snapshot(); // [!code highlight]
+ sandbox = await Sandbox.create({ // [!code highlight]
+ source: { type: "snapshot", snapshotId: snap.snapshotId }, // [!code highlight]
+ timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
+ });
+ sandboxCreatedAt = Date.now();
+ sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
+ await emit({
+ type: "status", state: "active", at: Date.now(),
+ sandboxId: sandbox.sandboxId, sandboxExpiresAt,
+ snapshotId: snap.snapshotId,
+ });
+ lastActivityAt = Date.now();
+ } else {
+ // Idle — snapshot and hibernate indefinitely.
+ await emit({ type: "status", state: "hibernating", at: Date.now() });
+ snapshot = await sandbox.snapshot(); // [!code highlight]
+ hibernated = true;
+ await emit({
+ type: "status", state: "hibernated", at: Date.now(),
+ snapshotId: snapshot.snapshotId,
+ });
+ }
+ continue;
+ }
+
+ if (outcome.command === "/destroy") { destroyed = true; break; }
+
+ counter += 1;
+ await runCommandAndStream(sandbox, `cmd-${counter}`, outcome.command);
+ lastActivityAt = Date.now();
+ await emit({ type: "activity", at: lastActivityAt });
+ }
+ } finally {
+ if (!hibernated) {
+ try {
+ if (sandbox.status === "running") await sandbox.stop();
+ } catch { /* best-effort */ }
+ }
+ await emit({ type: "status", state: "destroyed", at: Date.now() });
+ await emit({
+ type: "result",
+ status: "destroyed",
+ durationMs: Date.now() - startedAt,
+ });
+ }
+}
+```
+
+
+
+
+
+Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
+
+```typescript title="app/api/sandbox/start/route.ts" lineNumbers
+import { start, getRun } from "workflow/api";
+import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
+
+export async function POST(req: Request) {
+ let body: { runId?: string } = {};
+ try {
+ const text = await req.text();
+ if (text) body = JSON.parse(text);
+ } catch { /* ignore malformed body */ }
+
+ // Reconnect path: if the client sends a known runId, stream the durable
+ // event log from the beginning so the UI can rehydrate.
+ if (body.runId) {
+ const run = getRun(body.runId);
+ if (await run.exists) { // [!code highlight]
+ const readable = run.getReadable({ startIndex: 0 }); // [!code highlight]
+ return new Response(readable.pipeThrough(ndjson()), {
+ headers: {
+ "Content-Type": "application/x-ndjson",
+ "x-workflow-run-id": body.runId,
+ "x-workflow-reconnected": "true",
+ "Cache-Control": "no-cache, no-transform",
},
- },
+ });
+ }
+ // Stale runId — fall through to start fresh.
+ }
+
+ const run = await start(sandboxSessionWorkflow, []);
+ return new Response(run.readable.pipeThrough(ndjson()), {
+ headers: {
+ "Content-Type": "application/x-ndjson",
+ "x-workflow-run-id": run.runId,
+ "Cache-Control": "no-cache, no-transform",
},
});
+}
- const result = await agent.stream({
- messages: await convertToModelMessages(messages),
- writable: getWritable(),
+function ndjson() {
+ return new TransformStream({
+ transform(chunk, controller) {
+ controller.enqueue(JSON.stringify(chunk) + "\n");
+ },
});
+}
+```
- return { messages: result.messages };
+```typescript title="app/api/sandbox/command/route.ts" lineNumbers
+import { commandHook } from "@/workflows/sandbox-session";
+
+export async function POST(req: Request) {
+ const { runId, command } = (await req.json()) as { runId?: string; command?: string };
+
+ if (!runId || typeof command !== "string") {
+ return Response.json({ error: "runId and command are required" }, { status: 400 });
+ }
+
+ try {
+ await commandHook.resume(runId, { command }); // [!code highlight]
+ return Response.json({ ok: true });
+ } catch (error) {
+ const msg = error instanceof Error ? error.message.toLowerCase() : "";
+ if (msg.includes("not found") || msg.includes("expired")) {
+ return Response.json({ ok: false, note: "session expired" }, { status: 410 });
+ }
+ throw error;
+ }
}
```
-## Saga Pattern for Cleanup
+
+
+
+
+On mount, if a `runId` is stashed in `localStorage`, reconnect to the existing run. Otherwise start fresh. Commands are POSTed to `/command` — output lands on the `/start` stream.
+
+```tsx title="components/sandbox-runner.tsx" lineNumbers
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { SandboxEvent } from "@/workflows/sandbox-session";
+
+const RUN_ID_KEY = "sandbox.runId";
+
+export function SandboxRunner() {
+ const [events, setEvents] = useState([]);
+ const runIdRef = useRef(null);
+ const didReconnectRef = useRef(false);
+
+ const consume = useCallback(async (res: Response) => {
+ if (!res.ok || !res.body) return;
+ runIdRef.current = res.headers.get("x-workflow-run-id");
+ if (runIdRef.current) {
+ localStorage.setItem(RUN_ID_KEY, runIdRef.current); // [!code highlight]
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ setEvents((prev) => [...prev, JSON.parse(line) as SandboxEvent]);
+ } catch { /* malformed line */ }
+ }
+ }
+ }, []);
+
+ const openStream = useCallback(
+ async (runId?: string) => {
+ setEvents([]);
+ const res = await fetch("/api/sandbox/start", {
+ method: "POST",
+ headers: runId ? { "Content-Type": "application/json" } : undefined,
+ body: runId ? JSON.stringify({ runId }) : undefined,
+ });
+ await consume(res);
+ },
+ [consume]
+ );
+
+ // Auto-reconnect on mount if a runId is stashed.
+ useEffect(() => {
+ if (didReconnectRef.current) return;
+ didReconnectRef.current = true;
+ const stored = localStorage.getItem(RUN_ID_KEY);
+ if (stored) openStream(stored); // [!code highlight]
+ }, [openStream]);
+
+ const start = useCallback(() => {
+ localStorage.removeItem(RUN_ID_KEY);
+ runIdRef.current = null;
+ openStream();
+ }, [openStream]);
+
+ const sendCommand = useCallback(async (command: string) => {
+ if (!runIdRef.current) return;
+ const res = await fetch("/api/sandbox/command", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ runId: runIdRef.current, command }),
+ });
+ if (res.status === 410) localStorage.removeItem(RUN_ID_KEY);
+ }, []);
+
+ const destroy = useCallback(async () => {
+ await sendCommand("/destroy");
+ localStorage.removeItem(RUN_ID_KEY);
+ }, [sendCommand]);
+
+ // Render events as a terminal-style log. Drive UI state from `status` events
+ // (active / hibernating / hibernated / resuming / refreshing / destroyed).
+ return null;
+}
+```
+
+
+
+
+
+## How It Works
+
+1. **One workflow = one session.** The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember.
+2. **Hook created once.** `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`.
+3. **Two timer branches.** The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone — no timer, no compute.
+4. **Proactive refresh.** `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS`. Hitting this triggers a snapshot + immediate new sandbox from that snapshot, rolling over the hard cap without user intervention.
+5. **`sandbox.snapshot()` stops the VM.** It's documented as part of the snapshot process — don't call `stop()` separately.
+6. **Resume = new sandbox.** `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a fresh VM from the snapshot. The new sandbox has a different `sandboxId`; filesystem, installed packages, and git history are preserved.
+7. **Reconnect by runId.** `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, who rebuilds UI state from the replay.
+8. **Exit only on `/destroy`.** The workflow loop has no hard deadline of its own. Individual sandboxes time out; the session doesn't.
+
+## Pitfalls
+
+### `sandbox.stop()` is terminal
+
+A stopped sandbox cannot be restarted — you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later.
+
+### `snapshot()` already stops the VM
+
+Calling `stop()` after `snapshot()` either errors or is a no-op depending on timing. Snapshot takes care of it.
+
+### New `sandboxId` after resume and refresh
+
+Both `resuming` (idle → command) and `refreshing` (near-hard-cap rotation) create a new sandbox with a new `sandboxId`. Emit it on the subsequent `status: "active"` event and have the UI read from there, not from the initial `created` event.
+
+### Keep the refresh margin generous
+
+`snapshot()` + `Sandbox.create({ source })` takes real time (typically tens of seconds). If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; 5 minutes is comfortable.
+
+### Don't call `writable.close()` inside a workflow function
+
+Stream closure must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. The runtime closes the underlying writable when the workflow returns.
+
+### Handle stale `runId` gracefully
+
+Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.
+
+### Keep the hook outside the loop
+
+Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
-Combine sandbox orchestration with the [saga pattern](/docs/cookbook/common-patterns/saga) to ensure sandboxes are always cleaned up, even when a step in the middle of your pipeline fails.
+## Key APIs
-The example above uses a try/catch around the command execution loop. For more complex pipelines with multiple resources (sandbox + database + external API), push compensation functions onto a stack as shown in the [saga recipe](/docs/cookbook/common-patterns/saga).
+- [`Sandbox.create`](https://vercel.com/docs/sandbox) — provision a VM (runtime, source, timeout)
+- [`sandbox.runCommand`](https://vercel.com/docs/sandbox) — execute a command; implicit step
+- [`sandbox.snapshot`](https://vercel.com/docs/sandbox) — save state and stop the VM; returns `Snapshot`
+- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for user commands
+- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
+- [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
+- [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
diff --git a/docs/content/docs/cookbook/meta.json b/docs/content/docs/cookbook/meta.json
index ef260fb046..2665de6b65 100644
--- a/docs/content/docs/cookbook/meta.json
+++ b/docs/content/docs/cookbook/meta.json
@@ -1,5 +1,5 @@
{
"title": "Cookbook",
"defaultOpen": true,
- "pages": ["common-patterns", "agent-patterns", "integrations", "advanced"]
+ "pages": ["agent-patterns", "common-patterns", "integrations", "advanced"]
}
diff --git a/docs/content/docs/foundations/common-patterns.mdx b/docs/content/docs/foundations/common-patterns.mdx
deleted file mode 100644
index 8d4974730e..0000000000
--- a/docs/content/docs/foundations/common-patterns.mdx
+++ /dev/null
@@ -1,265 +0,0 @@
----
-title: Common Patterns
-description: Implement distributed patterns using familiar async/await syntax with no new APIs to learn.
-type: guide
-summary: Apply sequential, parallel, timeout, and composition patterns in workflows.
-prerequisites:
- - /docs/foundations/workflows-and-steps
-related:
- - /docs/foundations/errors-and-retries
- - /docs/foundations/hooks
----
-
-Common distributed patterns are simple to implement in workflows and require learning no new syntax. You can just use familiar async/await patterns.
-
-## Sequential Execution
-
-The simplest way to orchestrate steps is to execute them one after another, where each step can be dependent on the previous step.
-
-```typescript lineNumbers
-declare function validateData(data: unknown): Promise; // @setup
-declare function processData(data: string): Promise; // @setup
-declare function storeData(data: string): Promise; // @setup
-
-export async function dataPipelineWorkflow(data: unknown) {
- "use workflow";
-
- const validated = await validateData(data);
- const processed = await processData(validated);
- const stored = await storeData(processed);
-
- return stored;
-}
-```
-
-## Parallel Execution
-
-When you need to execute multiple steps in parallel, you can use `Promise.all` to run them all at the same time.
-
-```typescript lineNumbers
-declare function fetchUser(userId: string): Promise<{ name: string }>; // @setup
-declare function fetchOrders(userId: string): Promise<{ items: string[] }>; // @setup
-declare function fetchPreferences(userId: string): Promise<{ theme: string }>; // @setup
-
-export async function fetchUserData(userId: string) {
- "use workflow";
-
- const [user, orders, preferences] = await Promise.all([ // [!code highlight]
- fetchUser(userId), // [!code highlight]
- fetchOrders(userId), // [!code highlight]
- fetchPreferences(userId) // [!code highlight]
- ]); // [!code highlight]
-
- return { user, orders, preferences };
-}
-```
-
-This not only applies to steps - since [`sleep()`](/docs/api-reference/workflow/sleep) and [`webhook`](/docs/api-reference/workflow/create-webhook) are also just promises, we can await those in parallel too.
-We can also use `Promise.race` instead of `Promise.all` to stop executing promises after the first one completes.
-
-```typescript lineNumbers
-import { sleep, createWebhook } from "workflow";
-declare function executeExternalTask(webhookUrl: string): Promise; // @setup
-
-export async function runExternalTask(userId: string) {
- "use workflow";
-
- const webhook = createWebhook();
- await executeExternalTask(webhook.url); // Send the webhook somewhere
-
- // Wait for the external webhook to be hit, with a timeout of 1 day,
- // whichever comes first
- await Promise.race([ // [!code highlight]
- webhook, // [!code highlight]
- sleep("1 day"), // [!code highlight]
- ]); // [!code highlight]
-
- console.log("Done")
-}
-```
-
-## A Full Example
-
-Here's a simplified example taken from the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator), to illustrate how sequential and parallel execution can be combined.
-
-```typescript lineNumbers
-import { createWebhook, sleep, type Webhook } from "workflow"
-declare function makeCardText(prompt: string): Promise; // @setup
-declare function makeCardImage(text: string): Promise; // @setup
-declare function sendRSVPEmail(friend: string, webhook: Webhook): Promise; // @setup
-declare function sendBirthdayCard(text: string, image: string, rsvps: unknown[], email: string): Promise; // @setup
-
-async function birthdayWorkflow(
- prompt: string,
- email: string,
- friends: string[],
- birthday: Date
-) {
- "use workflow";
-
- // Generate a birthday card with sequential steps
- const text = await makeCardText(prompt)
- const image = await makeCardImage(text)
-
- // Create webhooks for each friend who's invited to the birthday party
- const webhooks = friends.map(_ => createWebhook())
-
- // Send out all the RSVP invites in parallel steps
- await Promise.all(
- friends.map(
- (friend, i) => sendRSVPEmail(friend, webhooks[i])
- )
- )
-
- // Collect RSVPs as they are made without blocking the workflow
- let rsvps = []
- webhooks.map(
- webhook => webhook
- .then(req => req.json())
- .then(( { rsvp } ) => rsvps.push(rsvp))
- )
-
- // Wait until the birthday
- await sleep(birthday)
-
- // Send birthday card with as many rsvps were collected
- await sendBirthdayCard(text, image, rsvps, email)
-
- return { text, image, status: "Sent" }
-}
-```
-
-## Timeout Pattern
-
-A common requirement is adding timeouts to operations that might take too long. Use `Promise.race` with `sleep()` to implement this pattern.
-
-```typescript lineNumbers
-import { sleep } from "workflow";
-declare function processData(data: string): Promise; // @setup
-
-export async function processWithTimeout(data: string) {
- "use workflow";
-
- const result = await Promise.race([ // [!code highlight]
- processData(data), // [!code highlight]
- sleep("30s").then(() => "timeout" as const), // [!code highlight]
- ]); // [!code highlight]
-
- if (result === "timeout") {
- // In workflows, any thrown error exits the workflow (FatalError is for steps)
- throw new Error("Processing timed out after 30 seconds");
- }
-
- return result;
-}
-```
-
-This pattern works with any promise-returning operation including steps, hooks, and webhooks. For example, you can add a timeout to a webhook that waits for external input:
-
-```typescript lineNumbers
-import { sleep, createWebhook } from "workflow";
-declare function sendApprovalRequest(requestId: string, webhookUrl: string): Promise; // @setup
-
-export async function waitForApproval(requestId: string) {
- "use workflow";
-
- const webhook = createWebhook<{ approved: boolean }>();
- await sendApprovalRequest(requestId, webhook.url);
-
- const result = await Promise.race([ // [!code highlight]
- webhook.then((req) => req.json()), // [!code highlight]
- sleep("7 days").then(() => ({ timedOut: true }) as const), // [!code highlight]
- ]); // [!code highlight]
-
- if ("timedOut" in result) {
- throw new Error("Approval request expired after 7 days");
- }
-
- return result.approved;
-}
-```
-
-## Workflow Composition
-
-Workflows can call other workflows, enabling you to break complex processes into reusable building blocks. There are two approaches depending on your needs.
-
-### Direct Await (Flattening)
-
-Call a child workflow directly using `await`. This "flattens" the child workflow into the parent - the child's steps execute inline within the parent workflow's context.
-
-```typescript lineNumbers
-declare function sendEmail(userId: string): Promise; // @setup
-declare function sendPushNotification(userId: string): Promise; // @setup
-declare function createAccount(userId: string): Promise; // @setup
-declare function setupPreferences(userId: string): Promise; // @setup
-
-// Child workflow
-export async function sendNotifications(userId: string) {
- "use workflow";
-
- await sendEmail(userId);
- await sendPushNotification(userId);
- return { notified: true };
-}
-
-// Parent workflow calls child directly
-export async function onboardUser(userId: string) {
- "use workflow";
-
- await createAccount(userId);
- await sendNotifications(userId); // [!code highlight]
- await setupPreferences(userId);
-
- return { userId, status: "onboarded" };
-}
-```
-
-With direct await, the parent workflow waits for the child to complete before continuing. The child's steps appear in the parent's event log as if they were called directly from the parent.
-
-### Background Execution via Step
-
-To run a child workflow independently without blocking the parent, use a step that calls [`start()`](/docs/api-reference/workflow-api/start). This launches the child workflow in the background.
-
-```typescript lineNumbers
-import { start } from "workflow/api";
-declare function generateReport(reportId: string): Promise; // @setup
-declare function fulfillOrder(orderId: string): Promise<{ id: string }>; // @setup
-declare function sendConfirmation(orderId: string): Promise; // @setup
-
-// Step that starts a workflow in the background
-async function triggerReportGeneration(reportId: string) {
- "use step";
-
- const run = await start(generateReport, [reportId]); // [!code highlight]
- return run.runId;
-}
-
-// Parent workflow
-export async function processOrder(orderId: string) {
- "use workflow";
-
- const order = await fulfillOrder(orderId);
-
- // Fire off report generation without waiting
- const reportRunId = await triggerReportGeneration(orderId); // [!code highlight]
-
- // Continue immediately - report generates in background
- await sendConfirmation(orderId);
-
- return { orderId, reportRunId };
-}
-```
-
-With background execution, the parent workflow continues immediately after starting the child. The child workflow runs independently with its own event log and can be monitored separately using the returned `runId`.
-
-
-If you want the child workflow to run on the latest deployment rather than the current one, you can pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. This is currently a Vercel-specific feature. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
-
-
-**Choose direct await when:**
-- The parent needs the child's result before continuing
-- You want a single, unified event log
-
-**Choose background execution when:**
-- The parent doesn't need to wait for the result
-- You want separate workflow runs for observability
diff --git a/docs/content/docs/foundations/index.mdx b/docs/content/docs/foundations/index.mdx
index c11fa163cd..528c3cf5a6 100644
--- a/docs/content/docs/foundations/index.mdx
+++ b/docs/content/docs/foundations/index.mdx
@@ -17,9 +17,6 @@ Workflow programming can be a slight shift from how you traditionally write real
Trigger workflows and track their execution using the `start()` function.
-
- Common patterns useful in workflows.
-
Types of errors and how retrying work in workflows.
diff --git a/docs/content/docs/foundations/meta.json b/docs/content/docs/foundations/meta.json
index 69c338d929..299085da7a 100644
--- a/docs/content/docs/foundations/meta.json
+++ b/docs/content/docs/foundations/meta.json
@@ -3,7 +3,6 @@
"pages": [
"workflows-and-steps",
"starting-workflows",
- "common-patterns",
"errors-and-retries",
"hooks",
"streaming",
diff --git a/docs/content/docs/foundations/serialization.mdx b/docs/content/docs/foundations/serialization.mdx
index 311d781c7c..e909b3aad6 100644
--- a/docs/content/docs/foundations/serialization.mdx
+++ b/docs/content/docs/foundations/serialization.mdx
@@ -185,7 +185,7 @@ async function doublePoint(point: Point) {
2. **`WORKFLOW_DESERIALIZE`**: A static method that receives the serialized data and returns a new class instance
-3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization
+3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time — no manual registration step is required
### Requirements
diff --git a/docs/content/docs/foundations/starting-workflows.mdx b/docs/content/docs/foundations/starting-workflows.mdx
index 000a4d099c..b3335fa789 100644
--- a/docs/content/docs/foundations/starting-workflows.mdx
+++ b/docs/content/docs/foundations/starting-workflows.mdx
@@ -213,6 +213,6 @@ export async function GET(request: Request) {
Now that you understand how to start workflows and track their execution:
-- Learn about [Common Patterns](/docs/foundations/common-patterns) for organizing complex workflows
+- Browse the [Cookbook](/cookbook) for copy-paste recipes covering composition, scheduling, timeouts, and more
- Explore [Errors & Retrying](/docs/foundations/errors-and-retries) to handle failures gracefully
- Check the [`start()` API Reference](/docs/api-reference/workflow-api/start) for complete details
diff --git a/docs/lib/cookbook-tree.ts b/docs/lib/cookbook-tree.ts
index 81099e7ec6..cca5a77183 100644
--- a/docs/lib/cookbook-tree.ts
+++ b/docs/lib/cookbook-tree.ts
@@ -12,15 +12,15 @@ export type RecipeCategory =
| 'advanced';
export const categoryOrder: RecipeCategory[] = [
- 'common-patterns',
'agent-patterns',
+ 'common-patterns',
'integrations',
'advanced',
];
export const categoryLabels: Record = {
- 'common-patterns': 'Common Patterns',
'agent-patterns': 'Agent Patterns',
+ 'common-patterns': 'Common Patterns',
integrations: 'Integrations',
advanced: 'Advanced',
};
@@ -28,23 +28,20 @@ export const categoryLabels: Record = {
/** Map from slug → category folder for URL construction */
export const slugToCategory: Record = {
// Common Patterns
+ 'sequential-and-parallel': 'common-patterns',
+ 'workflow-composition': 'common-patterns',
saga: 'common-patterns',
batching: 'common-patterns',
'rate-limiting': 'common-patterns',
- 'fan-out': 'common-patterns',
scheduling: 'common-patterns',
+ timeouts: 'common-patterns',
idempotency: 'common-patterns',
webhooks: 'common-patterns',
- 'content-router': 'common-patterns',
- 'child-workflows': 'common-patterns',
- 'distributed-abort-controller': 'common-patterns',
// Agent Patterns
'durable-agent': 'agent-patterns',
- 'tool-streaming': 'agent-patterns',
'human-in-the-loop': 'agent-patterns',
- 'tool-orchestration': 'agent-patterns',
- 'stop-workflow': 'agent-patterns',
+ 'agent-cancellation': 'agent-patterns',
// Integrations
'ai-sdk': 'integrations',
@@ -52,16 +49,29 @@ export const slugToCategory: Record = {
'chat-sdk': 'integrations',
// Advanced
+ 'child-workflows': 'advanced',
+ 'distributed-abort-controller': 'advanced',
'serializable-steps': 'advanced',
- 'durable-objects': 'advanced',
- 'isomorphic-packages': 'advanced',
- 'custom-serialization': 'advanced',
'publishing-libraries': 'advanced',
};
/** All recipe metadata, keyed by slug */
export const recipes: Record = {
// Common Patterns
+ 'sequential-and-parallel': {
+ slug: 'sequential-and-parallel',
+ title: 'Sequential & Parallel Execution',
+ description:
+ 'Compose steps with familiar async/await patterns — sequential await, Promise.all, and Promise.race against durable sleeps and webhooks.',
+ category: 'common-patterns',
+ },
+ 'workflow-composition': {
+ slug: 'workflow-composition',
+ title: 'Workflow Composition',
+ description:
+ 'Call workflows from other workflows by direct await (flatten into the parent) or background spawn via start() (separate run).',
+ category: 'common-patterns',
+ },
saga: {
slug: 'saga',
title: 'Transactions & Rollbacks (Saga)',
@@ -83,18 +93,18 @@ export const recipes: Record = {
'Handle 429 responses and transient failures with RetryableError and exponential backoff.',
category: 'common-patterns',
},
- 'fan-out': {
- slug: 'fan-out',
- title: 'Fan-Out & Parallel Delivery',
- description:
- 'Send a message to multiple channels or recipients in parallel with independent failure handling.',
- category: 'common-patterns',
- },
scheduling: {
slug: 'scheduling',
title: 'Sleep, Scheduling & Timed Workflows',
description:
- 'Use durable sleep to schedule actions minutes, hours, days, or weeks into the future.',
+ 'Schedule future actions with durable sleep and race sleeps against hooks to let external events cancel the workflow early.',
+ category: 'common-patterns',
+ },
+ timeouts: {
+ slug: 'timeouts',
+ title: 'Timeouts',
+ description:
+ 'Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep.',
category: 'common-patterns',
},
idempotency: {
@@ -111,27 +121,6 @@ export const recipes: Record = {
'Receive HTTP callbacks from external services, process them durably, and respond inline.',
category: 'common-patterns',
},
- 'content-router': {
- slug: 'content-router',
- title: 'Conditional Routing',
- description:
- 'Inspect a payload and route it to different step handlers based on its content.',
- category: 'common-patterns',
- },
- 'child-workflows': {
- slug: 'child-workflows',
- title: 'Child Workflows',
- description:
- 'Spawn and orchestrate child workflows from a parent, polling for completion and handling partial failures.',
- category: 'common-patterns',
- },
- 'distributed-abort-controller': {
- slug: 'distributed-abort-controller',
- title: 'Distributed Abort Controller',
- description:
- 'Build a cross-process abort controller using workflow streams and hooks to coordinate cancellation by semantic ID.',
- category: 'common-patterns',
- },
// Agent Patterns
'durable-agent': {
@@ -141,13 +130,6 @@ export const recipes: Record = {
'Replace a stateless AI agent with a durable one that survives crashes, retries tool calls, and streams output.',
category: 'agent-patterns',
},
- 'tool-streaming': {
- slug: 'tool-streaming',
- title: 'Tool Streaming',
- description:
- 'Stream real-time progress updates from tools to the UI while they execute.',
- category: 'agent-patterns',
- },
'human-in-the-loop': {
slug: 'human-in-the-loop',
title: 'Human-in-the-Loop',
@@ -155,18 +137,11 @@ export const recipes: Record = {
'Pause an AI agent to wait for human approval, then resume based on the decision.',
category: 'agent-patterns',
},
- 'tool-orchestration': {
- slug: 'tool-orchestration',
- title: 'Tool Orchestration',
+ 'agent-cancellation': {
+ slug: 'agent-cancellation',
+ title: 'Agent Cancellation',
description:
- 'Choose between step-level and workflow-level tools, or combine both for complex tool implementations.',
- category: 'agent-patterns',
- },
- 'stop-workflow': {
- slug: 'stop-workflow',
- title: 'Stop Workflow',
- description:
- 'Gracefully cancel a running agent workflow using a hook signal.',
+ 'Cancel a running agent from the outside — Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook for a graceful exit.',
category: 'agent-patterns',
},
@@ -194,32 +169,25 @@ export const recipes: Record = {
},
// Advanced
- 'serializable-steps': {
- slug: 'serializable-steps',
- title: 'Serializable Steps',
- description:
- 'Wrap non-serializable objects (like AI model providers) inside step functions so they can cross the workflow boundary.',
- category: 'advanced',
- },
- 'durable-objects': {
- slug: 'durable-objects',
- title: 'Durable Objects',
+ 'child-workflows': {
+ slug: 'child-workflows',
+ title: 'Child Workflows',
description:
- 'Model long-lived stateful entities as workflows that persist state across requests.',
+ 'Spawn and orchestrate child workflows from a parent, polling for completion and handling partial failures.',
category: 'advanced',
},
- 'isomorphic-packages': {
- slug: 'isomorphic-packages',
- title: 'Isomorphic Packages',
+ 'distributed-abort-controller': {
+ slug: 'distributed-abort-controller',
+ title: 'Distributed Abort Controller',
description:
- 'Publish reusable workflow packages that work both inside and outside the workflow runtime.',
+ 'Build a cross-process abort controller using workflow streams and hooks to coordinate cancellation by semantic ID.',
category: 'advanced',
},
- 'custom-serialization': {
- slug: 'custom-serialization',
- title: 'Custom Serialization',
+ 'serializable-steps': {
+ slug: 'serializable-steps',
+ title: 'Serializable Steps',
description:
- 'Make custom classes survive workflow serialization using the WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE protocol.',
+ 'Wrap non-serializable objects (like AI model providers) inside step functions so they can cross the workflow boundary.',
category: 'advanced',
},
'publishing-libraries': {
diff --git a/docs/next.config.ts b/docs/next.config.ts
index 07e1a9fb2d..cb9be79bf3 100644
--- a/docs/next.config.ts
+++ b/docs/next.config.ts
@@ -113,10 +113,47 @@ const config: NextConfig = {
destination: '/worlds',
permanent: true,
},
- // Redirect old control-flow-patterns to common-patterns
+ // Foundations "Common Patterns" page was retired in favor of dedicated
+ // cookbook recipes. Path-level redirect lands visitors on the cookbook
+ // overview where each pattern (Sequential & Parallel, Workflow
+ // Composition, Timeouts, etc.) has its own page. Note: anchor fragments
+ // from old links (#timeout-pattern, #direct-await-flattening, etc.) are
+ // dropped on redirect — Next.js redirects() does not match anchors.
+ {
+ source: '/docs/foundations/common-patterns',
+ destination: '/cookbook',
+ permanent: true,
+ },
{
source: '/docs/foundations/control-flow-patterns',
- destination: '/docs/foundations/common-patterns',
+ destination: '/cookbook',
+ permanent: true,
+ },
+ // Cookbook: child-workflows and distributed-abort-controller moved
+ // from common-patterns (now "Reliability Patterns") to advanced
+ {
+ source: '/cookbook/common-patterns/child-workflows',
+ destination: '/cookbook/advanced/child-workflows',
+ permanent: true,
+ },
+ {
+ source: '/cookbook/common-patterns/distributed-abort-controller',
+ destination: '/cookbook/advanced/distributed-abort-controller',
+ permanent: true,
+ },
+ // Cookbook: stop-workflow → agent-stop-signal → agent-cancellation.
+ // The page now covers both Hard Cancellation (run.cancel()) and Stop
+ // Signal (hook + Promise.race) as named patterns, so the broader
+ // "Agent Cancellation" title fits both. Both prior URLs land directly
+ // on the current page (no redirect chains).
+ {
+ source: '/cookbook/agent-patterns/stop-workflow',
+ destination: '/cookbook/agent-patterns/agent-cancellation',
+ permanent: true,
+ },
+ {
+ source: '/cookbook/agent-patterns/agent-stop-signal',
+ destination: '/cookbook/agent-patterns/agent-cancellation',
permanent: true,
},
];
diff --git a/docs/scripts/check-docs-smoke.mjs b/docs/scripts/check-docs-smoke.mjs
index e7e69e807f..14c9f01285 100644
--- a/docs/scripts/check-docs-smoke.mjs
+++ b/docs/scripts/check-docs-smoke.mjs
@@ -158,11 +158,11 @@ const checks = [
),
},
{
- name: 'HTML meta - docs common patterns',
+ name: 'HTML meta - cookbook sequential & parallel',
run: () =>
assertHtmlMeta(
- '/docs/foundations/common-patterns',
- '/og/foundations/common-patterns/image.png'
+ '/cookbook/common-patterns/sequential-and-parallel',
+ '/og/cookbook/common-patterns/sequential-and-parallel/image.png'
),
},
{
@@ -198,8 +198,11 @@ const checks = [
run: () => assertPngResponse('/og/getting-started/image.png'),
},
{
- name: 'OG docs foundations image',
- run: () => assertPngResponse('/og/foundations/common-patterns/image.png'),
+ name: 'OG cookbook common-patterns image',
+ run: () =>
+ assertPngResponse(
+ '/og/cookbook/common-patterns/sequential-and-parallel/image.png'
+ ),
},
{
name: 'OG docs reference image',
diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts
index cd87a308b6..89c529c44a 100644
--- a/packages/nitro/src/builders.ts
+++ b/packages/nitro/src/builders.ts
@@ -8,6 +8,18 @@ import {
import type { Nitro } from 'nitro/types';
import { join } from 'pathe';
+/**
+ * Forward string entries from Nitro's `externals.external` config to the
+ * workflow builder's esbuild `external` option. RegExp and function entries
+ * are skipped since esbuild's `external` only supports literal strings.
+ */
+function getNitroStringExternals(nitro: Nitro): string[] | undefined {
+ const externals = nitro.options.externals?.external?.filter(
+ (entry): entry is string => typeof entry === 'string'
+ );
+ return externals && externals.length > 0 ? externals : undefined;
+}
+
export class VercelBuilder extends VercelBuildOutputAPIBuilder {
constructor(nitro: Nitro) {
super({
@@ -15,6 +27,7 @@ export class VercelBuilder extends VercelBuildOutputAPIBuilder {
workingDir: nitro.options.rootDir,
dirs: ['.'], // Different apps that use nitro have different directories
runtime: nitro.options.workflow?.runtime,
+ externalPackages: getNitroStringExternals(nitro),
}),
buildTarget: 'vercel-build-output-api',
});
@@ -41,6 +54,7 @@ export class LocalBuilder extends BaseBuilder {
workingDir: nitro.options.rootDir,
watch: nitro.options.dev,
dirs: ['.'], // Different apps that use nitro have different directories
+ externalPackages: getNitroStringExternals(nitro),
}),
buildTarget: 'next', // Placeholder, not actually used
});
diff --git a/packages/nitro/src/index.test.ts b/packages/nitro/src/index.test.ts
index 318ee3cc77..4e57f51aa6 100644
--- a/packages/nitro/src/index.test.ts
+++ b/packages/nitro/src/index.test.ts
@@ -1,14 +1,23 @@
import { describe, expect, it } from 'vitest';
+import { LocalBuilder, VercelBuilder } from './builders.js';
import nitroModule from './index.js';
-function createNitroStub({ routing }: { routing: boolean }) {
+function createNitroStub({
+ routing,
+ externals,
+}: {
+ routing: boolean;
+ externals?: {
+ external?: Array boolean)>;
+ };
+}) {
return {
routing,
options: {
alias: {},
buildDir: '/tmp/.nitro',
dev: false,
- externals: {},
+ externals: externals ?? {},
handlers: [],
preset: 'node-server',
rootDir: '/tmp/project',
@@ -47,3 +56,47 @@ describe('@workflow/nitro virtual handlers', () => {
);
});
});
+
+describe('@workflow/nitro externals forwarding', () => {
+ for (const [label, Builder] of [
+ ['VercelBuilder', VercelBuilder],
+ ['LocalBuilder', LocalBuilder],
+ ] as const) {
+ describe(label, () => {
+ it('leaves externalPackages undefined when nitro externals are empty', () => {
+ const nitro = createNitroStub({ routing: true });
+ const builder = new Builder(nitro) as any;
+ expect(builder.config.externalPackages).toBeUndefined();
+ });
+
+ it('forwards string entries from nitro.options.externals.external', () => {
+ const nitro = createNitroStub({
+ routing: true,
+ externals: { external: ['fsevents', 'pg'] },
+ });
+ const builder = new Builder(nitro) as any;
+ expect(builder.config.externalPackages).toEqual(['fsevents', 'pg']);
+ });
+
+ it('skips RegExp and function entries', () => {
+ const nitro = createNitroStub({
+ routing: true,
+ externals: {
+ external: [/pkg/, () => true, 'fsevents'],
+ },
+ });
+ const builder = new Builder(nitro) as any;
+ expect(builder.config.externalPackages).toEqual(['fsevents']);
+ });
+
+ it('leaves externalPackages undefined when all entries are non-strings', () => {
+ const nitro = createNitroStub({
+ routing: true,
+ externals: { external: [/pkg/, () => true] },
+ });
+ const builder = new Builder(nitro) as any;
+ expect(builder.config.externalPackages).toBeUndefined();
+ });
+ });
+ }
+});
diff --git a/skills/workflow-init/SKILL.md b/skills/workflow-init/SKILL.md
index 38cb919971..2b043a0858 100644
--- a/skills/workflow-init/SKILL.md
+++ b/skills/workflow-init/SKILL.md
@@ -3,7 +3,7 @@ name: workflow-init
description: Install and configure Vercel Workflow SDK before it exists in node_modules. Use when the user asks to "install workflow", "set up workflow", "add durable workflows", "configure workflow sdk", or "init workflow" for Next.js, Express, Hono, Fastify, NestJS, Nitro, Nuxt, Astro, SvelteKit, or Vite.
metadata:
author: Vercel Inc.
- version: '1.2'
+ version: '1.3'
---
# workflow-init
@@ -68,7 +68,7 @@ Then follow the "Create Your Project" section of the chosen guide.
## Concept questions (pre-install)
If the user asks conceptual questions before installing, fetch:
- https://workflow-sdk.dev/docs/foundations/workflows-and-steps
-- https://workflow-sdk.dev/docs/foundations/common-patterns
+- https://workflow-sdk.dev/cookbook
## Handoff
When setup is complete, tell the user: **Use `/workflow` for ongoing development** - it reads the versioned docs bundled in `node_modules/workflow/docs/`.