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.

1,077 lines • 52.2 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 runs a tool loop like [`ToolLoopAgent`](/docs/agents/building-agents) and adds automatic state persistence, tool schema serialization, and 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                 | Available with durable non-streaming model calls |
| **`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.

The two classes have different options, callbacks, and results. Read [Differences from ToolLoopAgent](#differences-from-toolloopagent) before you move code from one to the other.

## Installation

```bash
npm install @ai-sdk/workflow workflow@5
```

`@ai-sdk/workflow` requires Workflow 5, as well as the `ai` package and `zod` 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-5.5',
  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-5.5' });

// Provider instance
import { openai } from '@ai-sdk/openai';
new WorkflowAgent({ model: openai('gpt-6-astra') });
```

## 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-5.5',
    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()),
});
```

The transform also forwards `reset-step` events emitted by `WorkflowAgent` on
retries.
Clients remove partial parts from the failed model-call step before processing
the retried output.

## 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/client';
import { useMemo } from 'react';

export default function Chat() {
  const transport = useMemo(
    () =>
      new WorkflowChatTransport({
        api: '/api/chat',
        maxConsecutiveErrors: 5,
      }),
    [],
  );

  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 { createUIMessageStreamResponse } from 'ai';
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',
  );
  if (!Number.isSafeInteger(startIndex) || startIndex < 0) {
    return Response.json(
      { error: 'startIndex must be a non-negative safe integer' },
      { status: 400 },
    );
  }

  const run = await getRun(runId);
  const readable = run
    .getReadable({ startIndex: 0 })
    .pipeThrough(
      createModelCallToUIChunkTransform({ uiStartIndex: startIndex }),
    );

  return createUIMessageStreamResponse({
    stream: readable,
    headers: {
      'x-workflow-run-id': runId,
    },
  });
}
```

`WorkflowChatTransport` counts `UIMessageChunk` objects, while the durable
`WorkflowAgent` stream stores raw `ModelCallStreamPart` objects. Replay the raw
stream from index `0` and apply the non-negative UI cursor in
`createModelCallToUIChunkTransform()` as shown above. Negative start indexes
require a durable stream that already stores `UIMessageChunk` objects and
cannot be used with this raw-to-UI conversion.

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

Human approval is configured on the tool definition with `needsApproval` or on
the agent with `toolApproval`. The `toolApproval` configuration takes precedence
over `needsApproval`. When a tool call needs user approval, 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-5.5',
  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.

Use `toolApproval` when approval depends on the tool set, the call, or the
runtime context, or when a tool does not set `needsApproval`. The functions in
the configuration run in workflow context, outside of the durable model steps:

```ts
const agent = new WorkflowAgent({
  model: 'anthropic/claude-sonnet-5.5',
  tools: {
    bookFlight: tool({
      description: 'Book a flight',
      inputSchema: z.object({ flightId: z.string() }),
      execute: bookFlightStep,
    }),
  },
  toolApproval: {
    bookFlight: 'user-approval',
  },
});
```

A call that the configuration marks as `'approved'` runs in the same step. A
call that it marks as `'denied'` does not run: the model receives an
`execution-denied` tool result and the loop continues. Neither decision pauses
the workflow. `step.content` records both decisions with an automatic approval
request and an approval response. Two details differ from `ToolLoopAgent`. The
tool message in `step.response.messages` holds only the tool result, while
`ToolLoopAgent` also adds the approval response part there. In `stream()`, the
approval parts follow the tool results in `step.content`, while `ToolLoopAgent`
places them next to the tool calls.

### Signed Tool Approvals

Client-supplied message history can be modified before it is replayed to the
workflow. For tools that perform sensitive operations, configure
`experimental_toolApprovalSecret` to authenticate approval requests:

```ts highlight="4"
const agent = new WorkflowAgent({
  model: 'anthropic/claude-sonnet-5.5',
  experimental_toolApprovalSecret: {
    environmentVariable: 'TOOL_APPROVAL_SECRET',
  },
  tools: {
    bookFlight: tool({
      description: 'Book a flight',
      inputSchema: z.object({ flightId: z.string() }),
      needsApproval: true,
      execute: bookFlightStep,
    }),
  },
});
```

The agent HMAC-signs the approval ID, tool call ID, tool name, and validated
input. When approved message history is replayed, a missing or invalid
signature prevents the tool from executing. The signature is preserved through
the durable model-call stream, `createModelCallToUIChunkTransform()`,
`addToolApprovalResponse()`, and `convertToModelMessages()`.

Use a high-entropy secret of at least 32 bytes and make the same secret
available to every worker that can issue or resume an approval. Keep old keys
available while approvals signed with them are pending; changing the secret
invalidates those pending approvals.

`WorkflowAgent` passes only the environment variable name into signing and
verification steps. Each step reads the secret from its local environment, and
the raw value is never included in step arguments, durable stream parts,
callbacks, or telemetry events. Configure the environment variable on every
worker, and do not put the secret value in `runtimeContext` or `toolsContext`.

You can also provide `experimental_toolApprovalSecret` to `agent.stream()`.
The stream-level value overrides the constructor default.

## Loop Control

`stream()` does not apply a default step limit. When `stopWhen` is omitted, it
continues until the model stops calling tools or another natural termination
condition is met. `generate()` stops after 20 steps by default, like
`ToolLoopAgent`, and logs the same warning when it reaches that limit.

<Note>
  A model that repeatedly calls tools can make an unlimited number of model
  calls. Configure an explicit stop condition when you need to bound execution
  time and cost.
</Note>

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
});
```

With `stream()`, omitting `stopWhen` already lets the agent continue until it
has finished calling tools. You can make that intent explicit with
`isLoopFinished()`, which also removes the 20-step default of `generate()`:

```ts
import { isLoopFinished } from 'ai';

const result = await agent.stream({
  messages,
  stopWhen: isLoopFinished(),
});
```

For `stream()`, `isLoopFinished()` is equivalent to omitting `stopWhen`.
Use it with caution because a model that keeps calling tools can run
indefinitely and incur significant costs. See
[`isLoopFinished()`](/docs/reference/ai-sdk-core/loop-finished).

## 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 descriptions and `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-5.5',
  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-5.5',
  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 {};
  },
});
```

A `model`, `toolChoice`, `activeTools`, or generation setting returned from
`prepareStep` applies to the current step only, as in `ToolLoopAgent`. Later
steps use the agent or call values again unless `prepareStep` returns an
override for them too. Returned `messages`, `instructions`, `runtimeContext`,
and `toolsContext` values stay in effect for later steps.

`prepareStep` can also be passed per call in `generate()` and `stream()`.
`prepareCall` is a constructor option only.

## 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-5.5',

  onStart({ messages }) {
    console.log(`Agent started with ${messages.length} messages`);
  },

  onStepStart({ stepNumber }) {
    console.log(`Step ${stepNumber} starting`);
  },

  onToolExecutionStart({ toolCall }) {
    console.log(`Calling tool: ${toolCall.toolName}`);
  },

  onToolExecutionEnd({ toolCall, success, durationMs }) {
    console.log(`Tool finished: ${toolCall.toolName}`, {
      success,
      durationMs,
    });
  },

  onStepEnd({ usage, finishReason }) {
    console.log('Step done:', { finishReason });
  },

  onEnd({ steps, totalUsage }) {
    console.log(`Completed in ${steps.length} steps`);
  },
});
```

For concrete tool sets, `WorkflowAgentToolExecutionStartEvent` and
`WorkflowAgentToolExecutionEndEvent` preserve the relationship between each
tool name and its input, context, and output types. TypeScript narrows the
nested `toolCall` directly, but use `Extract` or a user-defined type guard when
you need to narrow the correlated `toolContext` or `output` fields by tool name.

Tool input callbacks (`onInputStart`, `onInputDelta`, and
`onInputAvailable`) are also preserved by `WorkflowAgent`. The model call runs
inside a durable step, while callback functions remain in the workflow
context because arbitrary functions cannot cross the step boundary. As a
result, `WorkflowAgent` records the callback events during the model step and
replays them in order immediately after that step completes, before tool
execution and step lifecycle callbacks. They do not run concurrently with
model generation and cannot provide in-flight cancellation or backpressure.
Each callback receives its tool's `toolsContext` entry after
`contextSchema` validation.

For highly fragmented tool inputs, `onInputDelta` replay data is part of the
durable model-step result. Only configure `onInputDelta` when each generated
delta is needed; omit it to avoid retaining delta replay data.

The deprecated `experimental_onStart` and `experimental_onStepStart` names
remain available for backwards compatibility. When both the stable and
experimental name are provided in the same constructor or `stream()` call, the
stable callback is used.

## 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>;
```

## Differences from ToolLoopAgent

`WorkflowAgent` and [`ToolLoopAgent`](/docs/agents/building-agents) both run a tool loop, but they are separate implementations with separate APIs. `WorkflowAgent` does not implement the [`Agent`](/docs/reference/ai-sdk-core/agent) interface, so you cannot pass it to `createAgentUIStream`, `createAgentUIStreamResponse`, or `pipeAgentUIStreamToResponse`. Code written for one class usually needs changes before it runs on the other.

### Options that only ToolLoopAgent supports

TypeScript rejects these options on `WorkflowAgent`. If you pass them anyway, for example from JavaScript, they are ignored without an error unless the table says otherwise.

| ToolLoopAgent option                                            | With WorkflowAgent                                                                                                                                                       |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `toolOrder`                                                     | Not supported. Tools are sent in the order of the `tools` object.                                                                                                        |
| `callOptionsSchema` and the `options` call parameter            | Not supported. Pass settings to `generate()` or `stream()` directly.                                                                                                     |
| `experimental_toolCallers`                                      | Not supported.                                                                                                                                                           |
| `experimental_steering`                                         | Not supported. To add messages between steps, return `messages` from `prepareStep`.                                                                                      |
| `experimental_transform`                                        | Not supported. Transform `run.readable` with `pipeThrough()` where you create the response.                                                                              |
| `onStepError`                                                   | Not supported. Use `onError`.                                                                                                                                            |
| `onLanguageModelCallStart`, `onLanguageModelCallEnd`            | Not supported.                                                                                                                                                           |
| `include` in the constructor                                    | Pass `include` to `generate()`. `stream()` has no `include` option and does not record request data in its steps. Use `includeRawChunks` to receive raw provider chunks. |
| `timeout` in the constructor                                    | Pass `timeout` to `generate()` or `stream()`.                                                                                                                            |
| `experimental_telemetry`                                        | Use `telemetry`.                                                                                                                                                         |
| `experimental_onToolCallStart`, `experimental_onToolCallFinish` | Use `onToolExecutionStart` and `onToolExecutionEnd`.                                                                                                                     |

### Options with a different shape

| Option                                                                                                                               | ToolLoopAgent                                                | WorkflowAgent                                                                                                                                                                                               |
| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Generation settings, `instructions`, `toolChoice`, `activeTools`, `stopWhen`, `output`, `telemetry`, `prepareStep`, `repairToolCall` | Constructor only. Use `prepareCall` to change them per call. | Constructor, or per call in `generate()` and `stream()`. The per-call value overrides the constructor value.                                                                                                |
| `abortSignal`                                                                                                                        | Per call.                                                    | Constructor or per call.                                                                                                                                                                                    |
| `experimental_sandbox`                                                                                                               | Per call.                                                    | Constructor or per call.                                                                                                                                                                                    |
| `timeout`                                                                                                                            | A number or an object, in the constructor or per call.       | A number or an object, per call. The object has no tool timeouts, and `stepMs` covers the model call without tool execution. See [Deadlines, cancellation, and replay](#deadlines-cancellation-and-replay). |
| `experimental_toolApprovalSecret`                                                                                                    | The secret, as a `string` or `Uint8Array`.                   | `{ environmentVariable: string }`, the name of an environment variable that holds the secret. See [Signed Tool Approvals](#signed-tool-approvals).                                                          |
| `runtimeContext`, `toolsContext`                                                                                                     | Any JavaScript value.                                        | Serializable values. See [runtimeContext and toolsContext](#runtimecontext-and-toolscontext).                                                                                                               |

### Default step limit

|                 | `generate()`                                                 | `stream()`                                                   |
| --------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `ToolLoopAgent` | 20 steps. A warning is logged when the limit stops the loop. | 20 steps. A warning is logged when the limit stops the loop. |
| `WorkflowAgent` | 20 steps. A warning is logged when the limit stops the loop. | No limit.                                                    |

### prepareCall

In `ToolLoopAgent`, `prepareCall` receives the settings together with the `prompt` or `messages` and the typed `options` of the call, and the object it returns replaces the call settings. In `WorkflowAgent`:

- It receives `messages` and no `options`. A string `prompt` is converted to a user message first.
- The fields it returns are merged into the call. Fields you leave out keep their values, so you do not need the `({ options, ...rest }) => ({ ...rest })` pattern.
- It cannot return `tools`. The tools are fixed when the agent is created.

### prepareStep

- `messages` uses the provider prompt format (`LanguageModelV4Prompt`) in the argument and in the return value, and it includes the system message. `ToolLoopAgent` uses `ModelMessage[]` without the system message.
- The argument has no `responseMessages`. `initialInstructions`, `instructions`, and `initialMessages` are available.
- You can return `headers`. They are merged over the headers of the call, and the request keeps the `user-agent` value that identifies the agent. `ToolLoopAgent` does not accept `headers` from `prepareStep`.

### Callbacks

The callbacks have the same names, but most of them receive smaller event objects than in `ToolLoopAgent`:

| Callback               | Event fields in WorkflowAgent                                                                                  |
| ---------------------- | -------------------------------------------------------------------------------------------------------------- |
| `onStart`              | `model`, `messages`, `runtimeContext`, `toolsContext`                                                          |
| `onStepStart`          | `stepNumber`, `model`, `messages`, `steps`, `runtimeContext`, `toolsContext`                                   |
| `onToolExecutionStart` | `toolCall`, `stepNumber`, `messages`, `toolContext`                                                            |
| `onToolExecutionEnd`   | `toolCall`, `stepNumber`, `messages`, `toolContext`, `durationMs`, `success`, and `output` or `error`          |
| `onStepEnd`            | The step result, as in `ToolLoopAgent`                                                                         |
| `onEnd`                | `steps`, `messages`, `text`, `finishReason`, `usage`, `totalUsage`, `output`, `runtimeContext`, `toolsContext` |

`ToolLoopAgent` events also carry a `callId`, and its `onToolExecutionEnd` event uses `toolExecutionMs` and `toolOutput` where `WorkflowAgent` uses `durationMs`, `success`, `output`, and `error`.

The callbacks also run at different times:

- After a failed run, `stream()` calls `onEnd` before it rejects. `generate()` and `ToolLoopAgent` do not call `onEnd` for a failed run.
- `onAbort` is called by `generate()` and by `stream()`, and its event has only `steps`. `ToolLoopAgent` calls `onAbort` only for `stream()`, with `callId`, `reason`, and `steps`.
- An aborted `stream()` call resolves with the steps that finished. An aborted `generate()` call rejects.

### Results

`generate()` returns the same result type as `ToolLoopAgent`. If a model returns its structured output separately from its text, `generate()` still parses the text of the last step.

`stream()` works differently from `ToolLoopAgent`:

- It resolves when the run has finished, with a plain object that has `messages`, `steps`, `toolCalls`, `toolResults`, `finishReason`, `totalUsage`, and `output`, plus `error` when the model stream reported one. It has none of the streams and promises of `StreamTextResult`, such as `textStream`, `fullStream`, or `text`. Stream parts go to the `writable` you pass in. Read the final text from `result.steps.at(-1)?.text`.
- `messages` contains the input messages followed by the generated messages. `ToolLoopAgent` returns only the generated messages, as `responseMessages`.
- `toolCalls` and `toolResults` describe the last step. In `ToolLoopAgent` they cover all steps.
- `output` is `undefined` unless you set an `output` specification. `ToolLoopAgent` defaults to the generated text.
- If the structured output cannot be parsed, the `stream()` call rejects. In `ToolLoopAgent`, the call resolves and only `result.output` rejects.
- If the model call throws, the `stream()` call rejects with that error. In `ToolLoopAgent`, the call resolves and the error is reported through the stream and `onError`.
- Each step is a plain object. Its `request` is empty, and its `providerMetadata` is `{}` when the provider sends none. Steps from `generate()` match `ToolLoopAgent`.

### Tools

- Approval is configured with `needsApproval` on the tool or `toolApproval` on the agent. Approval request IDs have the form `approval-<toolCallId>`. `ToolLoopAgent` generates random IDs.
- For a tool whose `execute` function is an async generator, the last yielded value is the tool result, as in `ToolLoopAgent`. `ToolLoopAgent` also reports the earlier values as preliminary results. `WorkflowAgent` does not send them to the `writable`.
- Tool timeouts (`toolMs` and `tools` in the `timeout` object) are not supported.
- `experimental_refineToolInput` functions run in the workflow context after the model step returns. The `tool-call` chunk that `stream()` writes to `writable` for a tool without its own runtime validator, and the input of provider-executed tool calls, keep the unrefined value.

### Requests

Requests carry `ai-sdk-agent/workflow` in the `user-agent` header. `ToolLoopAgent` sends `ai-sdk-agent/tool-loop`.

### Timeouts

- `stepMs` limits one model call. In `ToolLoopAgent` it also covers the tool calls of the step.
- `generate()` ignores `firstChunkMs` and `chunkMs`. `ToolLoopAgent` also logs a warning for them.
- A timeout value that is not a finite number, or that is larger than 2147483647, is rejected with an `InvalidArgumentError`. So is a negative `stepMs`, `firstChunkMs`, or `chunkMs`. `ToolLoopAgent` does not validate the values.

## 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-5.5',
    instructions: 'You are a helpful assistant.',
    tools: { /* ... */ },
  });
```

Install the new package alongside `workflow`:

```bash
npm install @ai-sdk/workflow workflow@5
```

### 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 };
```

### Non-streaming generation

Use `agent.generate()` when you need the completed result without streaming:

```ts
export async function summarize(modelId: string, text: string) {
  'use workflow';

  const agent = new WorkflowAgent({
    model: modelId,
    instructions: 'Summarize the text briefly.',
  });
  const result = await agent.generate({ prompt: text });
  return { text: result.text, usage: result.usage };
}
```

Generation uses the provider's `doGenerate()` method and the same durable tool loop as streaming. It defaults to a maximum of 20 steps, while `stream()` remains unbounded unless you set `stopWhen`. Generation failures reject; stream model errors retain their existing `result.error` behavior.

The generated result uses core's final-step text and all-step content, tool results, warnings, and usage semantics. `responseMessages` contains generated messages only. `output` defaults to text and supports constructor or per-call output specifications. End callbacks run before complete-output parsing.

Use result getters inside the workflow, then return selected serializable fields through `run.returnValue`. Returning the whole result class is not supported. See the [generate reference](/docs/reference/ai-sdk-workflow/workflow-agent#generate) for options and remaining differences.

#### Approval round trips without streaming

Both `generate()` and `stream()` return approval requests even when no writable is provided. For generation, keep `result.responseMessages`, display the requested tool and input to the user, then append their decision to the next call:

```ts
const pending = await agent.generate({ messages });
// Persist these messages and collect the user's decision outside the workflow.
const continuation = [...messages, ...pending.responseMessages];

// In a later workflow invocation, using the same tool definitions:
const resumed = await agent.generate({
  messages: [
    ...continuation,
    {
      role: 'tool',
      content: [{ type: 'tool-approval-response', approvalId, approved }],
    },
  ],
});
```

Use the `approvalId` from the returned `tool-approval-request` and the user's `approved` decision. Preserve the request signature and original tool-call input when storing the conversation. Configure `experimental_toolApprovalSecret` with the same environment-variable reference for both invocations. Secrets are resolved inside durable signing and verification steps.

Local tools execute only after approval validation; denied tools produce an execution-denied result. Provider-executed approvals are forwarded to the provider. Tools that wait on Workflow hooks can also suspend and resume during generation.

#### Deadlines, cancellation, and replay

`timeout` accepts a number of milliseconds or an object:

```ts
const result = await agent.stream({
  messages,
  timeout: {
    totalMs: 5 * 60_000,
    stepMs: 60_000,
    firstChunkMs: 15_000,
    chunkMs: 10_000,
  },
});
```

A number has the same meaning as `totalMs`. Inside a workflow, it sets an absolute deadline for model calls. Each model step checks it before dispatch, before SDK retry attempts, and after the provider returns. The same deadline remains in effect after a tool suspends and resumes. SDK `maxRetries` governs model retries; the durable model step does not add another retry layer. Expiration before dispatch rejects with `TimeoutError`; expiration during SDK retry backoff rejects with `AbortError`. Neither starts a new retry.

The other values apply to one model call and start again for the next one:

- `stepMs` limits the whole model call: dispatch, SDK retries, and reading the response. Tool execution is not part of it.
- `firstChunkMs` limits the wait for the first output of the model call, counted from the start of the response stream.
- `chunkMs` limits the wait between two outputs.

`firstChunkMs` and `chunkMs` are used by `stream()` only. Only generated output counts for them: files, tool calls, and text, reasoning, or tool-input deltas that are not empty. Response metadata, start and end markers, and empty deltas do not start or reset these timers.

When one of these limits is reached, the model call is aborted with a `TimeoutError` and the run ends like an aborted run: `stream()` resolves with the steps that finished, `generate()` rejects with the error, and both call `onAbort`. The timers run inside the durable model step. A model step that has completed is not timed again on replay.

Each value must be a finite number of at most 2147483647, which is about 24.8 days. A negative `timeout` or `totalMs` is a deadline that has already passed. Any other invalid value is rejected with an `InvalidArgumentError` before the first model call.

A model deadline does not cancel a tool waiting on a Workflow hook. The run can remain suspended after that deadline. Once the hook resumes, any subsequent model call fails before dispatch if the deadline has expired. To stop the suspended run, use the Workflow runtime's `run.cancel()` API. Cancellation does not undo external side effects that already happened.

Workflow reuses completed durable model and tool steps on replay. Put external side effects inside step functions and use the external service's idempotency mechanism where necessary: completed-step replay does not guarantee exactly-once effects for an interrupted attempt.

Generated files and result getters are available inside workflow code. Return the data the caller needs, for example:

```ts
return {
  text: result.text,
  files: result.files.map(file => ({
    data: file.uint8Array,
    mediaType: file.mediaType,
  })),
  usage: result.usage,
  responseMessages: result.responseMessages,
};
```

The generated result class itself is not a supported `run.returnValue` shape. Request and response bodies are omitted unless explicitly enabled with `include`; available response headers, timestamps, IDs and provider metadata are retained. Workflow uses its existing callback shapes and preparation rules, and reports model response timing rather than complete tool/step timing. It does not implement ToolLoopAgent's `callOptionsSchema` abstraction or core's live streaming readers and HTTP response helpers.

### 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-5.5',
    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`, `onStart`, `onStepStart`, `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 `onToolExecutionStart` / `onToolExecutionEnd` lifecycle callbacks documented above. The deprecated `experimental_onStart` and `experimental_onStepStart` aliases remain available for backwards compatibility.

## 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

## Tool execution concurrency

Set `toolCallConcurrency` to limit how many local tool calls execute concurrently
within a step. Use `1` when calls must execute sequentially in model order:

```ts
const agent = new WorkflowAgent({
  model,
  tools,
  toolCallConcurrency: 1,
});
```

The limit applies to both `generate()` and `stream()`, including approved tool
calls resumed from message history. It must be a positive integer. By default,
all local calls execute concurrently; provider-executed tools are unaffected.
You can also return the setting from `prepareCall` or pass it to `generate()` or
`stream()`. Explicit call options take precedence over `prepareCall`, which takes
precedence over the constructor default.