WorkflowAgent
The WorkflowAgent from @ai-sdk/workflow is designed for building durable, resumable agents that run inside a workflow. It runs a tool loop like ToolLoopAgent 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 from the ai package.
The two classes have different options, callbacks, and results. Read Differences from ToolLoopAgent before you move code from one to the other.
Installation
@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:
Model Resolution
The model parameter accepts two forms:
Using the Agent in a Workflow
WorkflowAgent is designed to run inside a workflow function. The key integration points are:
- Mark your function with
'use workflow' - Pass
getWritable()to the agent'sstream()method - Start the workflow from your API route
End-to-End Example
Message Conversion
WorkflowAgent.stream() expects ModelMessage[], not UIMessage[]. When receiving messages from the client (via useChat), convert them first:
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:
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 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.
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:
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.
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
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:
Because the workflow is durable, the approval request survives process restarts — the user can approve hours later and the agent will resume.
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:
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, but does not log a warning when it reaches that limit.
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.
Control how many steps the agent can take:
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():
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().
Structured Output
Parse agent responses into typed objects using Output:
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.
runtimeContextis shared agent state that flows throughprepareStep, lifecycle callbacks, andonEnd. Treat it as immutable; return a new value fromprepareStepto update it for the current and subsequent steps.toolsContextis a per-tool map keyed by tool name. Each tool'sexecuteonly sees its own validated entry ascontext. Tools that declare acontextSchemavalidate their entry against the schema before execution.
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:
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:
prepareStep
Called before each step (LLM call). Use it to modify settings, manage context, or inject messages dynamically:
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, system, 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):
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:
Differences from ToolLoopAgent
WorkflowAgent and ToolLoopAgent both run a tool loop, but they are separate implementations with separate APIs. WorkflowAgent does not implement the 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 |
|---|---|
toolApproval | Set needsApproval on each tool. See Tool Approval. |
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_refineToolInput | Not supported. |
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, and timeout as an object | Pass timeout as a number of milliseconds to generate() or stream(). An object value fails with a TypeError. |
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, per call. It sets a deadline for model calls and does not cancel a tool that waits on a hook. See 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. |
runtimeContext, toolsContext | Any JavaScript value. | Serializable values. See 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. No warning is logged. | 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
messagesand nooptions. A stringpromptis 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
messagesuses the provider prompt format (LanguageModelV4Prompt) in the argument and in the return value, and it includes the system message.ToolLoopAgentusesModelMessage[]without the system message.- The argument has no
instructionsand noresponseMessages.initialInstructionsandinitialMessagesare available. - To change the system prompt, return
systemas a string. A returnedinstructionsvalue is ignored. - A returned
providerOptionsobject replaces theproviderOptionsof the agent.ToolLoopAgentmerges the two objects. - You can return
headers. They replace all headers of the call, including theuser-agentvalue that identifies the agent.ToolLoopAgentdoes not acceptheadersfromprepareStep. - You cannot return
toolOrder.
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()callsonEndbefore it rejects.generate()andToolLoopAgentdo not callonEndfor a failed run. onAbortis called bygenerate()and bystream(), and its event has onlysteps.ToolLoopAgentcallsonAbortonly forstream(), withcallId,reason, andsteps.- An aborted
stream()call resolves with the steps that finished. An abortedgenerate()call rejects. - If
generate()is aborted during a step with a custom abort reason, it callsonErrorwith that reason.ToolLoopAgentdoes not report aborts toonError.
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, andoutput, pluserrorwhen the model stream reported one. It has none of the streams and promises ofStreamTextResult, such astextStream,fullStream, ortext. Stream parts go to thewritableyou pass in. Read the final text fromresult.steps.at(-1)?.text. messagescontains the input messages followed by the generated messages.ToolLoopAgentreturns only the generated messages, asresponseMessages.toolCallsandtoolResultsdescribe the last step. InToolLoopAgentthey cover all steps.outputisundefinedunless you set anoutputspecification.ToolLoopAgentdefaults to the generated text.- If the structured output cannot be parsed, the
stream()call rejects. InToolLoopAgent, the call resolves and onlyresult.outputrejects. - If the model call throws, the
stream()call rejects with that error. InToolLoopAgent, the call resolves and the error is reported through the stream andonError. - Each step is a plain object. Its
requestis empty, itsmodel.provideris derived from the model ID of the response and can differ from the provider name, and itsproviderMetadatais{}when the provider sends none. Steps fromgenerate()matchToolLoopAgent. - A tool call for an unknown tool, or with input that does not match the schema, is missing from
contentandtoolCallsof the step. The model still receives the error as a tool result and the loop continues.generate()andToolLoopAgentkeep the call in the step, markedinvalid, with atool-errorpart.
WorkflowAgent does not log provider warnings. Read them from warnings on each step.
Tools
- Approval is configured with
needsApprovalon the tool. Approval request IDs have the formapproval-<toolCallId>.ToolLoopAgentgenerates random IDs. - A tool whose
executefunction is an async generator is not supported.ToolLoopAgenttreats every yielded value except the last one as a preliminary result.WorkflowAgentdoes not iterate the generator, and the tool result is an empty object. - In
stream(), theonInputStartandonInputAvailablehooks of a tool are not called when the provider sends the tool call without streaming its input first. They are called ingenerate(). - Tool timeouts (
toolMsandtoolsin thetimeoutobject) are not supported.
Requests
Requests carry ai-sdk-agent/workflow in the user-agent header. ToolLoopAgent sends ai-sdk-agent/tool-loop.
Migrating from DurableAgent
WorkflowAgent replaces the Workflow DevKit's DurableAgent. 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.
Install the new package alongside 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.
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.
See Loop Control for the full list of stop conditions.
Replace experimental_output with output
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.
needsApproval also accepts an async function so you can decide per-input
whether approval is required (see 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 before passing them to the agent — this is the pattern described in 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.
Non-streaming generation
Use agent.generate() when you need the completed result without streaming:
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 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:
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
Inside a workflow, timeout 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.
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:
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 for the full shape.
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 for detailed parameter documentation
- WorkflowChatTransport API Reference for stream reconnection options
- Building Agents for the in-memory
ToolLoopAgentalternative - 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:
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.