diff --git a/.changeset/nitro-forward-externals.md b/.changeset/nitro-forward-externals.md new file mode 100644 index 0000000000..dc84348b30 --- /dev/null +++ b/.changeset/nitro-forward-externals.md @@ -0,0 +1,5 @@ +--- +"@workflow/nitro": minor +--- + +Forward string entries from Nitro's `externals.external` config to the workflow builder's esbuild `external` option. diff --git a/docs/app/[lang]/(home)/components/intro/non-workflow.tsx b/docs/app/[lang]/(home)/components/intro/non-workflow.tsx index 7cd90633d7..6b8b9eebec 100644 --- a/docs/app/[lang]/(home)/components/intro/non-workflow.tsx +++ b/docs/app/[lang]/(home)/components/intro/non-workflow.tsx @@ -13,12 +13,12 @@ const Loading = ( ); const Success = ( -
+
); const ErrorIndicator = ( -
+
); diff --git a/docs/app/[lang]/(home)/components/intro/workflow.tsx b/docs/app/[lang]/(home)/components/intro/workflow.tsx index f4754f7840..7dd7db61c0 100644 --- a/docs/app/[lang]/(home)/components/intro/workflow.tsx +++ b/docs/app/[lang]/(home)/components/intro/workflow.tsx @@ -10,18 +10,15 @@ type WorkflowLog = { }; const Loading = ( - + ); const Success = ( -
+
); const ErrorIndicator = ( -
+
); 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
Result: {approvalResult}
; + } + return ( +
+
+
Flight: {flightId}
+
Passenger: {passenger}
+
Price: ${price}
+
+
+ {/* [!code highlight] */} + {/* [!code highlight] */} +
+
+ ); + } + // 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

{output}

; - } +## Adapting to your use case - const handleSubmit = async (approved: boolean) => { - setIsSubmitting(true); - await fetch("/api/hooks/approval", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ toolCallId, approved, comment }), - }); - setIsSubmitting(false); - }; - - return ( -
- {input && ( -
-
Flight: {input.flightId}
-
Passenger: {input.passenger}
-
Price: ${input.price}
-
- )} -