UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

713 lines (548 loc) 25.3 kB
--- title: WorkflowAgent description: Build durable, resumable agents with the WorkflowAgent from @ai-sdk/workflow. --- # WorkflowAgent The `WorkflowAgent` from `@ai-sdk/workflow` is designed for building **durable, resumable agents** that run inside a [workflow](https://vercel.com/docs/workflow). It provides the same agent loop as the [`ToolLoopAgent`](/docs/agents/building-agents), but adds automatic state persistence, tool schema serialization, and built-in tool approval flows that survive workflow step boundaries. ## Why Durable Agents? A standard `ToolLoopAgent` runs entirely in memory — if the process crashes, all progress is lost. For production agents that make multiple tool calls, this creates problems: - **Statefulness** — Long-running agent loops need to persist state across process boundaries - **Resumability** — If a step fails, you want to retry from the last checkpoint, not restart from scratch - **Human-in-the-loop** — Tools that require user approval need to pause the agent and resume later - **Observability** — Each tool call runs as a discrete workflow step, visible in dashboards `WorkflowAgent` solves these by running inside a workflow, where each tool execution is a durable step with automatic retries. ## When to Use WorkflowAgent vs ToolLoopAgent | | ToolLoopAgent | WorkflowAgent | | ----------------------- | ------------------------- | ----------------------------------------------- | | **Package** | `ai` | `@ai-sdk/workflow` | | **Runtime** | In-memory | Workflow | | **Durability** | Lost on crash | Survives restarts | | **Tool retries** | Manual | Automatic (via workflow steps) | | **Human approval** | Built-in | Built-in + survives suspension | | **`generate()` method** | Available | Not available | | **`stream()` method** | Available | Primary API | | **Stream output** | `streamText` return value | `writable` parameter with `ModelCallStreamPart` | For simpler use cases that don't need durability, use [`ToolLoopAgent`](/docs/agents/building-agents) from the `ai` package. ## Installation ```bash npm install @ai-sdk/workflow workflow ``` `@ai-sdk/workflow` requires the `ai` package and `zod` as peer dependencies. The `workflow` package provides the Workflow DevKit runtime (`getWritable`, `'use workflow'`, `'use step'`). ## Creating a WorkflowAgent Define an agent by instantiating the `WorkflowAgent` class with a model, instructions, and tools: ```ts import { WorkflowAgent } from '@ai-sdk/workflow'; import { tool } from 'ai'; import { z } from 'zod'; const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', instructions: 'You are a helpful assistant.', tools: { weather: tool({ description: 'Get weather for a location', inputSchema: z.object({ location: z.string(), }), execute: async ({ location }) => ({ location, temperature: 72, }), }), }, }); ``` ### Model Resolution The `model` parameter accepts two forms: ```ts // String — AI Gateway model ID new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6' }); // Provider instance import { openai } from '@ai-sdk/openai'; new WorkflowAgent({ model: openai('gpt-4o') }); ``` ## Using the Agent in a Workflow `WorkflowAgent` is designed to run inside a workflow function. The key integration points are: 1. Mark your function with `'use workflow'` 2. Pass `getWritable()` to the agent's `stream()` method 3. Start the workflow from your API route ### End-to-End Example ```ts filename="workflow/agent-chat.ts" import { WorkflowAgent, type ModelCallStreamPart } from '@ai-sdk/workflow'; import { convertToModelMessages, tool, type UIMessage } from 'ai'; import { getWritable } from 'workflow'; import { z } from 'zod'; export async function chat(messages: UIMessage[]) { 'use workflow'; const modelMessages = await convertToModelMessages(messages); const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', instructions: 'You are a flight booking assistant.', tools: { searchFlights: tool({ description: 'Search for available flights', inputSchema: z.object({ origin: z.string(), destination: z.string(), date: z.string(), }), execute: searchFlightsStep, }), bookFlight: tool({ description: 'Book a specific flight', inputSchema: z.object({ flightId: z.string(), passengerName: z.string(), }), execute: bookFlightStep, }), }, }); const result = await agent.stream({ messages: modelMessages, writable: getWritable<ModelCallStreamPart>(), }); return { messages: result.messages }; } ``` ```ts filename="app/api/chat/route.ts" import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow'; import { createUIMessageStreamResponse, type UIMessage } from 'ai'; import { start } from 'workflow/api'; import { chat } from '@/workflow/agent-chat'; export async function POST(request: Request) { const { messages }: { messages: UIMessage[] } = await request.json(); const run = await start(chat, [messages]); return createUIMessageStreamResponse({ stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), }); } ``` ### Message Conversion `WorkflowAgent.stream()` expects `ModelMessage[]`, not `UIMessage[]`. When receiving messages from the client (via `useChat`), convert them first: ```ts import { convertToModelMessages, type UIMessage } from 'ai'; export async function chat(messages: UIMessage[]) { 'use workflow'; const modelMessages = await convertToModelMessages(messages); const result = await agent.stream({ messages: modelMessages, // ... }); } ``` ### Writable Streams Unlike `ToolLoopAgent` where you consume the returned stream, `WorkflowAgent` writes raw `ModelCallStreamPart` chunks to a `writable` stream provided by the workflow runtime via `getWritable()`. At the response boundary, use `createModelCallToUIChunkTransform()` to convert these into `UIMessageChunk` objects for the client: ```ts import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow'; import { createUIMessageStreamResponse } from 'ai'; // Convert raw model stream parts → UI message chunks return createUIMessageStreamResponse({ stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), }); ``` ## Resumable Streaming with WorkflowChatTransport Workflow functions can time out or be interrupted by network failures. `WorkflowChatTransport` is a [`ChatTransport`](/docs/ai-sdk-ui/transport) implementation that handles these interruptions automatically — it detects when a stream ends without a `finish` event and reconnects to resume from where it left off. ```tsx filename="app/page.tsx" 'use client'; import { useChat } from '@ai-sdk/react'; import { WorkflowChatTransport } from '@ai-sdk/workflow'; import { useMemo } from 'react'; export default function Chat() { const transport = useMemo( () => new WorkflowChatTransport({ api: '/api/chat', maxConsecutiveErrors: 5, initialStartIndex: -50, // On page refresh, fetch last 50 chunks }), [], ); const { messages, sendMessage } = useChat({ transport }); // ... render chat UI } ``` The transport requires your POST endpoint to return an `x-workflow-run-id` response header, and a GET endpoint at `{api}/{runId}/stream` for reconnection: ```ts filename="app/api/chat/route.ts" import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow'; import { createUIMessageStreamResponse, type UIMessage } from 'ai'; import { start } from 'workflow/api'; import { chat } from '@/workflow/agent-chat'; export async function POST(request: Request) { const { messages }: { messages: UIMessage[] } = await request.json(); const run = await start(chat, [messages]); return createUIMessageStreamResponse({ stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), headers: { 'x-workflow-run-id': run.runId, }, }); } ``` ```ts filename="app/api/chat/[runId]/stream/route.ts" import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow'; import type { NextRequest } from 'next/server'; import { getRun } from 'workflow/api'; export async function GET( request: NextRequest, { params }: { params: Promise<{ runId: string }> }, ) { const { runId } = await params; const startIndex = Number( new URL(request.url).searchParams.get('startIndex') ?? '0', ); const run = await getRun(runId); const readable = run .getReadable({ startIndex }) .pipeThrough(createModelCallToUIChunkTransform()); return new Response(readable, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'x-workflow-run-id': runId, }, }); } ``` For the full API reference, see [`WorkflowChatTransport`](/docs/reference/ai-sdk-workflow/workflow-chat-transport). ## Tools as Workflow Steps Mark tool execute functions with `'use step'` to make them durable workflow steps. This gives each tool call: - **Automatic retries** — Failed tool calls are retried automatically (default: 3 attempts) - **Persistence** — Results survive process restarts - **Observability** — Each tool call appears as a discrete step in the workflow dashboard ```ts async function searchFlightsStep(input: { origin: string; destination: string; date: string; }) { 'use step'; const response = await fetch(`https://api.flights.example/search?...`); return response.json(); } async function bookFlightStep(input: { flightId: string; passengerName: string; }) { 'use step'; const response = await fetch('https://api.flights.example/book', { method: 'POST', body: JSON.stringify(input), }); return response.json(); } ``` Tools without `'use step'` still work but run as regular in-memory functions without durability guarantees. ## Tool Approval For `WorkflowAgent`, human approval is configured on the tool definition with `needsApproval`. This is specific to `WorkflowAgent`; for `generateText`, `streamText`, and `ToolLoopAgent`, use `toolApproval` instead. When a workflow tool has `needsApproval` set, the agent pauses and emits an approval request to the writable stream. The workflow suspends until the user approves or denies: ```ts const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', tools: { bookFlight: tool({ description: 'Book a flight', inputSchema: z.object({ flightId: z.string(), passengerName: z.string(), }), needsApproval: true, // Always require approval execute: bookFlightStep, }), cancelBooking: tool({ description: 'Cancel a booking', inputSchema: z.object({ bookingId: z.string() }), // Conditional approval based on input needsApproval: async input => { return input.bookingId.startsWith('VIP-'); }, execute: cancelBookingStep, }), }, }); ``` Because the workflow is durable, the approval request survives process restarts — the user can approve hours later and the agent will resume. ## Loop Control Control how many steps the agent can take: ```ts import { isStepCount } from 'ai'; const result = await agent.stream({ messages, stopWhen: isStepCount(10), // Stop after 10 LLM calls }); ``` If you want the agent to keep running until it has finished calling tools, you can also use `isLoopFinished()`: ```ts import { isLoopFinished } from 'ai'; const result = await agent.stream({ messages, stopWhen: isLoopFinished(), }); ``` `isLoopFinished()` lets the agent run until all tool calls have completed, but you should still pair it with `maxSteps` to avoid runaway loops. See https://ai-sdk.dev/v7/docs/reference/ai-sdk-core/loop-finished#isloopfinished. By default, the agent loops until the model stops calling tools (no maximum). ## Structured Output Parse agent responses into typed objects using `Output`: ```ts import { Output } from '@ai-sdk/workflow'; import { z } from 'zod'; const result = await agent.stream({ messages, output: Output.object({ schema: z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), summary: z.string(), }), }), }); console.log(result.output); // { sentiment: 'positive', summary: '...' } ``` ## Configuration Options `WorkflowAgent` accepts the same generation settings as `ToolLoopAgent` (`temperature`, `maxOutputTokens`, `topP`, etc.) plus workflow-specific options. ### runtimeContext and toolsContext Pass server-side state through the agent loop without putting it into the prompt. Use these instead of the previous `experimental_context` option. - `runtimeContext` is shared agent state that flows through `prepareStep`, lifecycle callbacks, and `onEnd`. Treat it as immutable; return a new value from `prepareStep` to update it for the current and subsequent steps. - `toolsContext` is a per-tool map keyed by tool name. Each tool's `execute` only sees its own validated entry as `context`. Tools that declare a `contextSchema` validate their entry against the schema before execution. ```ts filename="workflow/agent-chat.ts" import { WorkflowAgent } from '@ai-sdk/workflow'; import { tool } from 'ai'; import { z } from 'zod'; const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', tools: { weather: tool({ description: 'Get the weather for a city.', inputSchema: z.object({ city: z.string() }), contextSchema: z.object({ defaultUnit: z.enum(['celsius', 'fahrenheit']), }), execute: async ({ city }, { context }) => ({ city, unit: context.defaultUnit, }), }), }, // Shared agent state — available in `prepareStep`, lifecycle callbacks, and `onEnd`. runtimeContext: { tenantId: 'tenant_123', requestId: 'req_abc', plan: 'enterprise', }, // Per-tool context — each tool sees only its own validated entry. toolsContext: { weather: { defaultUnit: 'celsius' }, }, prepareStep: ({ runtimeContext }) => { if (runtimeContext.plan === 'enterprise') { return { temperature: 0.2 }; } return {}; }, }); ``` `runtimeContext` and `toolsContext` can also be passed per-call to `stream()`, where they override the constructor-level defaults. Because `WorkflowAgent` runs inside the Workflow runtime, context values may be persisted and replayed across workflow and step boundaries. Keep `runtimeContext`, `toolsContext`, and any context values returned from `prepareStep` serializable. Use plain data such as strings, numbers, booleans, arrays, plain objects, dates, URLs, maps, sets, and other Workflow-supported structured data. Do not put functions, class instances, symbols, `WeakMap`, `WeakSet`, database clients, or SDK clients in context. Pass identifiers or configuration data instead, and recreate non-serializable resources inside step functions. This differs from `ToolLoopAgent`, which runs in memory and can carry richer JavaScript values for the lifetime of a single process. With `WorkflowAgent`, treating context as durable data keeps workflow replay and step execution reliable. ### experimental_sandbox Pass a sandbox session when tools need an execution environment. The sandbox is available to tool `execute` functions as `experimental_sandbox` and to `prepareStep`, where you can override it for the current step: ```ts const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', tools: { shell: tool({ description: 'Run a shell command in the sandbox.', inputSchema: z.object({ command: z.string() }), execute: async ({ command }, { experimental_sandbox }) => { if (!experimental_sandbox) { throw new Error('Sandbox is not available'); } return experimental_sandbox.run({ command }); }, }), }, experimental_sandbox: sandbox, }); await agent.stream({ messages, writable: getWritable(), experimental_sandbox: requestSandbox, // Overrides the constructor default. }); ``` `experimental_sandbox` is a live runtime handle, not durable context. Do not store it in `runtimeContext` or `toolsContext`. If a tool runs as a separate workflow step, pass serializable sandbox identifiers or configuration and reattach inside that step. ### prepareCall Called once before the agent loop starts. Use it to transform model, instructions, or other settings based on runtime context: ```ts const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', prepareCall: async ({ model, tools, messages }) => { return { instructions: `Current time: ${new Date().toISOString()}`, }; }, }); ``` ### prepareStep Called before each step (LLM call). Use it to modify settings, manage context, or inject messages dynamically: ```ts const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', prepareStep: async ({ stepNumber, experimental_sandbox }) => { if (stepNumber > 5) { return { toolChoice: 'none' }; // Force text response after 5 steps } if (experimental_sandbox) { return { temperature: 0.2 }; } return {}; }, }); ``` Both `prepareCall` and `prepareStep` can also be passed per-call in `stream()`. ## Lifecycle Callbacks Agents provide lifecycle callbacks for logging, observability, and custom telemetry. All callbacks can be defined in the constructor (agent-wide) or in `stream()` (per-call). When both are provided, both fire (constructor first): ```ts const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', experimental_onStart({ modelId, messages }) { console.log('Agent started'); }, experimental_onStepStart({ stepNumber }) { console.log(`Step ${stepNumber} starting`); }, onToolExecutionStart({ toolCall }) { console.log(`Calling tool: ${toolCall.toolName}`); }, onToolExecutionEnd({ toolCall, toolOutput }) { console.log(`Tool finished: ${toolCall.toolName}`); }, onStepEnd({ usage, finishReason }) { console.log('Step done:', { finishReason }); }, onEnd({ steps, totalUsage }) { console.log(`Completed in ${steps.length} steps`); }, }); ``` ## Type Inference Infer the UI message type for type-safe client components: ```ts import { WorkflowAgent, InferWorkflowAgentUIMessage } from '@ai-sdk/workflow'; const myAgent = new WorkflowAgent({ // ... configuration }); export type MyAgentUIMessage = InferWorkflowAgentUIMessage<typeof myAgent>; ``` ## Migrating from `DurableAgent` `WorkflowAgent` replaces the Workflow DevKit's [`DurableAgent`](https://workflow-sdk.dev/docs/api-reference/workflow-ai/durable-agent). The two share the same core idea — a durable agent loop that runs inside a workflow — but `WorkflowAgent` moves the class into the AI SDK, tightens typing, and introduces first-class tool approval. If you are using `DurableAgent` today, follow the steps below to switch. ### Change the import and class name `DurableAgent` was exported from `workflow/ai`. `WorkflowAgent` is exported from `@ai-sdk/workflow`, alongside its helpers. ```diff - import { DurableAgent } from 'workflow/ai'; + import { WorkflowAgent, type ModelCallStreamPart } from '@ai-sdk/workflow'; - const agent = new DurableAgent({ + const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', instructions: 'You are a helpful assistant.', tools: { /* ... */ }, }); ``` Install the new package alongside `workflow`: ```bash npm install @ai-sdk/workflow ``` ### Write `ModelCallStreamPart`, not `UIMessageChunk` `DurableAgent` wrote `UIMessageChunk` objects directly to the writable returned by `getWritable()`. `WorkflowAgent` writes the lower-level `ModelCallStreamPart` shape and leaves the conversion to a transform at the response boundary. This keeps the durable stream provider-shaped and avoids baking a UI protocol into the workflow payload. ```diff // Inside the workflow await agent.stream({ messages, - writable: getWritable<UIMessageChunk>(), + writable: getWritable<ModelCallStreamPart>(), }); ``` ```diff // Inside the route handler + import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow'; return createUIMessageStreamResponse({ - stream: run.readable, + stream: run.readable.pipeThrough(createModelCallToUIChunkTransform()), }); ``` ### Replace `maxSteps` with `stopWhen` `DurableAgent` accepted `maxSteps` directly. `WorkflowAgent` uses the AI SDK's shared `stopWhen` conditions so the same stop logic works across `ToolLoopAgent`, `generateText`, and `streamText`. ```diff + import { isStepCount } from 'ai'; await agent.stream({ messages, - maxSteps: 10, + stopWhen: isStepCount(10), }); ``` See [Loop Control](/docs/agents/loop-control) for the full list of stop conditions. ### Replace `experimental_output` with `output` ```diff + import { Output } from '@ai-sdk/workflow'; await agent.stream({ messages, - experimental_output: Output.object({ schema }), + output: Output.object({ schema }), }); ``` The returned value is now on `result.output` (previously `result.experimental_output`). ### WorkflowAgent: Use `needsApproval` for human-in-the-loop tools With `DurableAgent`, tool approval was implemented by calling a Hook from inside the tool's `execute` function. `WorkflowAgent` makes approval a first-class tool property — the agent emits the approval request, suspends the workflow, and resumes automatically when the user responds. ```diff bookFlight: tool({ description: 'Book a flight', inputSchema: z.object({ flightId: z.string() }), + needsApproval: true, - execute: async (input) => { - const approved = await waitForApprovalHook(input); - if (!approved) throw new Error('Denied'); - return bookFlightStep(input); - }, + execute: bookFlightStep, }), ``` `needsApproval` also accepts an async function so you can decide per-input whether approval is required (see [Tool Approval](#tool-approval) above). ### `uiMessages` / `collectUIMessages` is gone `DurableAgent.stream()` returned accumulated `uiMessages` when `collectUIMessages: true` was set. `WorkflowAgent.stream()` returns `ModelMessage[]` on `result.messages` instead. For persistence, store `UIMessage[]` as your source of truth and call [`convertToModelMessages`](/docs/reference/ai-sdk-ui/convert-to-model-messages) before passing them to the agent — this is the pattern described in [Chatbot Message Persistence](/docs/ai-sdk-ui/chatbot-message-persistence). There is no built-in `ModelMessage` → `UIMessage` conversion, so avoid persisting `result.messages` as your only copy if you need to render the conversation in the UI later. ```diff const result = await agent.stream({ messages, writable: getWritable<ModelCallStreamPart>(), - collectUIMessages: true, }); - return { uiMessages: result.uiMessages }; + return { messages: result.messages }; ``` ### No `generate()` method `WorkflowAgent` only exposes `stream()`. If you were calling `agent.generate()`, switch to `stream()` and read `result.messages` / `result.output` once the promise resolves. ### Replace `experimental_context` with `runtimeContext` and `toolsContext` `WorkflowAgent` no longer accepts `experimental_context`. Split the value into shared agent state (`runtimeContext`) and per-tool state (`toolsContext`); each tool's `execute` then receives only its own validated entry as `context`. See [runtimeContext and toolsContext](#runtimecontext-and-toolscontext) for the full shape. ```diff const agent = new WorkflowAgent({ model: 'anthropic/claude-sonnet-4-6', tools: { weather: weatherTool }, - experimental_context: { tenantId: 'tenant_123', apiKey: 'sk-...' }, + runtimeContext: { tenantId: 'tenant_123' }, + toolsContext: { weather: { apiKey: 'sk-...' } }, }); ``` ### Everything else Other options carry over with the same names: `prepareStep`, `onStepEnd`, `onEnd`, `onError`, `toolChoice`, `activeTools`, `timeout`, `repairToolCall`, `experimental_sandbox`, and the usual generation settings (`temperature`, `maxOutputTokens`, `topP`, …). `WorkflowAgent` additionally adds `prepareCall` (runs once before the loop) and the `experimental_onStart` / `experimental_onStepStart` / `onToolExecutionStart` / `onToolExecutionEnd` lifecycle callbacks documented above. ## Next Steps - [WorkflowAgent API Reference](/docs/reference/ai-sdk-workflow/workflow-agent) for detailed parameter documentation - [WorkflowChatTransport API Reference](/docs/reference/ai-sdk-workflow/workflow-chat-transport) for stream reconnection options - [Building Agents](/docs/agents/building-agents) for the in-memory `ToolLoopAgent` alternative - [Loop Control](/docs/agents/loop-control) for advanced stop conditions