Dynamic model selection in prepareStep example
Example showing how to switch models based on step requirements in prepareStep:
```ts
import { ToolLoopAgent } from 'ai';
__PROVIDER_IMPORT__;
const agent = new ToolLoopAgent({
model: 'openai/gpt-4o-mini', // Default model
tools: {
// your tools
},
prepareStep: async ({ stepNumber, messages }) => {
// Use a stronger model for complex reasoning after initial steps
if (stepNumber > 2 && messages.length > 10) {
return {
model: __MODEL__,
};
}
// Continue with default settings
return {};
},
});
const result = await agent.generate({
prompt: '...',
});
```
Custom stop condition with text detection example
Example showing how to create a custom stopping condition that stops when the model generates text containing a specific marker:
```ts
import { ToolLoopAgent, StopCondition, ToolSet } from 'ai';
__PROVIDER_IMPORT__;
const tools = {
// your tools
} satisfies ToolSet;
const hasAnswer: StopCondition<typeof tools> = ({ steps }) => {
// Stop when the model generates text containing "ANSWER:"
return steps.some(step => step.text?.includes('ANSWER:')) ?? false;
};
const agent = new ToolLoopAgent({
model: __MODEL__,
tools,
stopWhen: hasAnswer,
});
const result = await agent.generate({
prompt: 'Find the answer and respond with "ANSWER: [your answer]"',
});
```
Forced tool calling pattern
You can force the agent to always use tools by combining toolChoice: 'required' with a done tool that has no execute function. This pattern ensures the agent uses tools for every step and stops only when it explicitly signals completion.
Tool without execute function stops loop
A tool that has no execute function acts as a termination signal. When the agent calls this tool, the loop stops because there's no function to execute.
Stop condition parameters
Custom stop conditions receive step information. The parameter object includes steps, which is an array of all previous steps with their results, including text, usage, toolCalls, and toolResults.
prepareStep model call setting overrides
prepareStep can override maxOutputTokens, temperature, topP, topK, presencePenalty, frequencyPenalty, stopSequences, seed, and reasoning. These overrides apply only to the current step. When a setting is omitted or undefined, the top-level value is used for that step. Defined falsy values such as temperature: 0, seed: 0, and an empty stopSequences array are preserved.
prepareStep message persistence
When you return a messages override from prepareStep, that changed list becomes the base for later steps. New assistant and tool response messages are appended to it as the loop continues. If you need to rebuild a step from discrete pieces instead of the persisted message state, use initialMessages for the original input and responseMessages for the model/tool response messages accumulated so far.
Manual loop with generateText example
Example showing how to implement your own agent loop using generateText for complete control:
```ts
import { generateText, ModelMessage } from 'ai';
__PROVIDER_IMPORT__;
const messages: ModelMessage[] = [{ role: 'user', content: '...' }];
let step = 0;
const maxSteps = 10;
while (step < maxSteps) {
const result = await generateText({
model: __MODEL__,
messages,
tools: {
// your tools here
},
});
messages.push(...result.responseMessages);
if (result.text) {
break; // Stop when model generates text
}
step++;
}
```
Default step limit safety measure
The default step limit of 20 steps using isStepCount(20) is a safety measure to prevent runaway loops that could result in excessive API calls and costs.
prepareStep callback for loop modification
The prepareStep callback runs before each step in the loop and defaults to the initial settings if you don't return any changes. Use it to modify settings, manage context, or implement dynamic behavior based on execution history. It receives messages for the current step, plus initialMessages and responseMessages when you need to distinguish the original input from assistant/tool messages accumulated in earlier steps.
stopWhen parameter for loop control
The stopWhen parameter controls when to stop execution when there are tool results in the last step. By default, agents stop after 20 steps using isStepCount(20). When you provide stopWhen, the agent continues executing after tool calls until a stopping condition is met. When the condition is an array, execution stops when any of the conditions are met.
Agent loop stops when
The agent loop continues until one of these conditions is met: a finish reasoning other than tool-calls is returned, a tool that is invoked does not have an execute function, a tool call needs approval, or a stop condition is met.
Combine multiple stopping conditions example
Example showing how to combine multiple stopping conditions with an array, where the loop stops when any condition is met:
```ts
import { ToolLoopAgent, isStepCount, hasToolCall } from 'ai';
__PROVIDER_IMPORT__;
const agent = new ToolLoopAgent({
model: __MODEL__,
tools: {
// your tools
},
stopWhen: [
isStepCount(20), // Maximum 20 steps
hasToolCall('someTool', 'done'), // Stop after calling either tool
],
});
const result = await agent.generate({
prompt: 'Research and analyze the topic',
});
```
isLoopFinished stopping condition example
Example showing how to use isLoopFinished() to allow the agent to run until it naturally stops making tool calls with no maximum step limit:
```ts
import { ToolLoopAgent, isLoopFinished } from 'ai';
__PROVIDER_IMPORT__;
const agent = new ToolLoopAgent({
model: __MODEL__,
tools: {
// your tools
},
stopWhen: isLoopFinished(), // No maximum step limit.
});
const result = await agent.generate({
prompt: 'Analyze this dataset and create a summary report',
});
```
prepareStep parameters and access
Both stopWhen and prepareStep receive detailed information about the current execution: model (current model configuration), stepNumber (current step number, 0-indexed), steps (all previous steps with their results), and messages (messages to be sent to the model).
isStepCount stopping condition example
Example showing how to use isStepCount to increase the default step limit from 20 to 50 in a ToolLoopAgent:
```ts
import { ToolLoopAgent, isStepCount } from 'ai';
__PROVIDER_IMPORT__;
const agent = new ToolLoopAgent({
model: __MODEL__,
tools: {
// your tools
},
stopWhen: isStepCount(50), // Increasing the default of 20 to 50.
});
const result = await agent.generate({
prompt: 'Analyze this dataset and create a summary report',
});
```
Manual loop control with generateText
For scenarios requiring complete control over the agent loop, you can use AI SDK Core functions (generateText and streamText) to implement your own loop management instead of using stopWhen and prepareStep. This approach provides maximum flexibility for complex workflows and gives complete control over message history management, step-by-step decision making, custom stopping conditions, dynamic tool and model selection, and error handling and recovery.
Accessing forced tool calling results
When using forced tool calling with a done tool that has no execute function, the final answer is available in result.staticToolCalls, which contains tool calls that weren't executed.
Custom stop condition with budget tracking example
Example showing how to create a custom stopping condition that tracks token usage and stops execution when cost exceeds a budget:
```ts
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
const totalUsage = steps.reduce(
(acc, step) => ({
inputTokens: acc.inputTokens + (step.usage?.inputTokens ?? 0),
outputTokens: acc.outputTokens + (step.usage?.outputTokens ?? 0),
}),
{ inputTokens: 0, outputTokens: 0 },
);
const costEstimate =
(totalUsage.inputTokens * 0.01 + totalUsage.outputTokens * 0.03) / 1000;
return costEstimate > 0.5; // Stop if cost exceeds $0.50
};
```
isLoopFinished() caution
Using isLoopFinished() should be done with caution because without a step limit, the agent could potentially run indefinitely or incur significant costs if the model keeps making tool calls.
Subagent cancellation handling with abortSignal
When the user cancels a request, the abortSignal propagates to the subagent. Always pass abortSignal through to the subagent's generate() call to ensure cleanup. If you abort the signal, the subagent stops executing and throws an AbortError, which stops the main agent's tool execution and main loop.
Basic subagent without streaming pattern
Create a subagent with its own model, instructions, and tools. Create a tool that calls the subagent's generate() method in its execute function for the main agent to use. This works when you don't need to show the subagent's progress in the UI, as the tool call blocks until the subagent completes, then returns the final text response.
When to use subagents: benefits vs tradeoffs
Use subagents when tasks require exploring large amounts of tokens, you need to parallelize independent research, context would grow beyond model limits, or you want to isolate tool access by capability. Avoid subagents when tasks are simple and focused, sequential processing suffices, context stays manageable, or all tools can safely coexist.
Subagent definition and purpose
A subagent is an agent that a parent agent can invoke. The parent delegates work via a tool, and the subagent executes autonomously before returning a result. Subagents run independently with their own context window.
Preliminary tool results accumulation behavior
Each yield in a preliminary tool results generator replaces the previous output entirely (it does not append). This means you need a way to accumulate the subagent's response into a complete message that grows over time. The readUIMessageStream utility handles this accumulation.
toModelOutput for context management in subagents
Use toModelOutput to control what the main agent's model actually sees. The full UIMessage with all the subagent's work is stored in the message history and displayed in the UI, but toModelOutput maps the tool's output to the tokens sent to the model. This allows the subagent to use hundreds of thousands of tokens for exploration while the main agent only consumes a brief summary.
Subagent offloading context-heavy tasks
With subagents, you can spin up a dedicated agent that uses hundreds of thousands of tokens and return only a focused summary (perhaps 1,000 tokens), keeping the main agent's context clean and coherent. The subagent does the heavy lifting while the main agent stays focused on orchestration.
Basic subagent code example without streaming
import { ToolLoopAgent, tool } from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';
const researchSubagent = new ToolLoopAgent({
model: __MODEL__,
instructions: `You are a research agent.
Summarize your findings in your final response.`,
tools: {
read: readFileTool,
search: searchTool,
},
});
const researchTool = tool({
description: 'Research a topic or question in depth.',
inputSchema: z.object({
task: z.string().describe('The research task to complete'),
}),
execute: async ({ task }, { abortSignal }) => {
const result = await researchSubagent.generate({
prompt: task,
abortSignal,
});
return result.text;
},
});
const mainAgent = new ToolLoopAgent({
model: __MODEL__,
instructions: 'You are a helpful assistant that can delegate research tasks.',
tools: {
research: researchTool,
},
});
Passing main agent conversation history to subagent
If you need to give a subagent access to the conversation history, the messages are available in the tool's execute function alongside abortSignal. Pass ...messages from the execute function parameters along with the specific task to the subagent's generate() call. Use this sparingly since passing full history defeats some of the context isolation benefits.
Streaming subagent progress with preliminary tool results
To show incremental progress as the subagent works, use preliminary tool results. Change the execute function from a regular function to an async generator (async function*). Each yield sends a preliminary result to the frontend. The readUIMessageStream utility reads each chunk from the stream and builds an ever-growing UIMessage containing all parts received so far.
Subagent context isolation
Each subagent invocation starts with a fresh context window. Subagents do not inherit the accumulated context from the main agent, which is one of the key benefits—it allows them to do heavy exploration without bloating the main conversation.
Subagent streaming complexity tradeoff
The basic pattern without streaming is simpler to implement and debug. Only add streaming when you need to show real-time progress in the UI.
Detecting streaming vs complete in subagent output
Check part.state === 'output-available' to detect if output exists. When state is output-available, check part.preliminary === true for streaming output or !part.preliminary for complete output.
InferAgentUIMessage for type-safe subagent rendering
Export InferAgentUIMessage alongside your agents for type safety in UI components. Import ToolLoopAgent and InferAgentUIMessage from 'ai', then define export type MainAgentMessage = InferAgentUIMessage<typeof mainAgent>;. Use this type in useChat<MainAgentMessage>() for proper typing.
Tool part states for rendering subagents
Tool part states are: input-streaming (tool input being generated), input-available (tool ready to execute), output-available (tool produced output, check preliminary flag), output-error (tool execution failed).
Subagent UI rendering example with useChat
'use client';
import { useChat } from '@ai-sdk/react';
import type { MainAgentMessage } from '@/lib/agents';
export function Chat() {
const { messages } = useChat<MainAgentMessage>();
return (
<div>
{messages.map(message =>
message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <p key={i}>{part.text}</p>;
case 'tool-research':
return (
<div>
{part.state !== 'input-streaming' && (
<div>Research: {part.input.task}</div>
)}
{part.state === 'output-available' && (
<div>
{part.output.parts.map((nestedPart, i) => {
switch (nestedPart.type) {
case 'text':
return <p key={i}>{nestedPart.text}</p>;
default:
return null;
}
})}
</div>
)}
</div>
);
default:
return null;
}
}),
)}
</div>
);
}
toModelOutput code example for subagent summary
const researchTool = tool({
description: 'Research a topic or question in depth.',
inputSchema: z.object({
task: z.string().describe('The research task to complete'),
}),
execute: async function* ({ task }, { abortSignal }) {
const result = await researchSubagent.stream({
prompt: task,
abortSignal,
});
for await (const message of readUIMessageStream({
stream: toUIMessageStream({ stream: result.stream }),
})) {
yield message;
}
},
toModelOutput: ({ output: message }) => {
const lastTextPart = message?.parts.findLast(p => p.type === 'text');
return {
type: 'text',
value: lastTextPart?.text ?? 'Task completed.',
};
},
});
Handling incomplete tool calls after cancellation
To avoid errors about incomplete tool calls in subsequent messages after cancellation, use convertToModelMessages with ignoreIncompleteToolCalls option set to true. This filters out tool calls that don't have corresponding results.
Streaming subagent code example with preliminary results
import { readUIMessageStream, toUIMessageStream, tool } from 'ai';
import { z } from 'zod';
const researchTool = tool({
description: 'Research a topic or question in depth.',
inputSchema: z.object({
task: z.string().describe('The research task to complete'),
}),
execute: async function* ({ task }, { abortSignal }) {
const result = await researchSubagent.stream({
prompt: task,
abortSignal,
});
for await (const message of readUIMessageStream({
stream: toUIMessageStream({ stream: result.stream }),
})) {
yield message;
}
},
});
Subagent instructions for summarization
For toModelOutput to extract a useful summary, the subagent must produce one. Add explicit instructions in the subagent's instructions like: 'IMPORTANT: When you have finished, write a clear summary of your findings as your final response. This summary will be returned to the main agent, so include all relevant information.' Without this instruction, the subagent might not produce a comprehensive summary.
Subagent tool approval limitation
Subagent tools cannot use approval flows such as toolApproval or the deprecated needsApproval. All tools in subagents must execute automatically without user confirmation.
ToolLoopAgent is the primary agent implementation
The ToolLoopAgent is the main agent class provided by the AI SDK for building agents with tools and loop control.
Agents overview and purpose
Agents are systems where large language models (LLMs) use tools in a loop to accomplish tasks. The AI SDK provides tools and patterns for building agents.
Agent documentation structure
The agents documentation covers: Overview (agent concepts and state management), Building Agents (creating ToolLoopAgent instances), Workflow Patterns (using core functions for complex workflows), Loop Control (stopWhen and prepareStep), Configuring Call Options (runtime inputs), Tool Approvals (review and approve tool calls), Policy-Based Tool Approvals (authorization rules with @ai-sdk/policy-opa), Subagents (delegating tasks to specialized agents), WorkflowAgent (durable resumable agents with @ai-sdk/workflow), and Terminal UI (interactive terminal interface).
WorkflowAgent.stream() method signature
WorkflowAgent.stream() accepts: messages (ModelMessage[]), writable (from getWritable<ModelCallStreamPart>()), stopWhen (loop control condition), output (Output configuration for structured response), and optional per-call overrides for prepareCall, prepareStep, runtimeContext, toolsContext, experimental_sandbox, and lifecycle callbacks.
WorkflowAgent toolsContext for per-tool state
toolsContext is a per-tool map keyed by tool name. Each tool's execute function receives only its own validated entry as context. Tools can declare a contextSchema to validate their entry against the schema before execution. Must be serializable and passed in the constructor or per-call to stream().
WorkflowAgent installation
Install WorkflowAgent with: 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').
WorkflowAgent GET reconnection endpoint example
Example: 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 } }); } This endpoint retrieves workflow run state and resumes streaming from a given startIndex.
WorkflowAgent POST endpoint example
Example: 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 } }); } This endpoint starts the workflow and returns a stream with the workflow run ID header.
WorkflowAgent runtimeContext for shared agent state
runtimeContext is shared agent state that flows through prepareStep, lifecycle callbacks, and onEnd. It is available to the agent but not individual tools. Treat it as immutable; return a new value from prepareStep to update it for the current and subsequent steps. Can be passed in the constructor or per-call to stream(). Must be serializable (strings, numbers, booleans, arrays, plain objects, dates, URLs, maps, sets) and cannot contain functions, class instances, symbols, WeakMap, WeakSet, or SDK clients.
WorkflowAgent with runtimeContext and toolsContext example
Example: 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 }) }) }, runtimeContext: { tenantId: 'tenant_123', requestId: 'req_abc', plan: 'enterprise' }, toolsContext: { weather: { defaultUnit: 'celsius' } }, prepareStep: ({ runtimeContext }) => { if (runtimeContext.plan === 'enterprise') return { temperature: 0.2 }; return {}; } }); This shows shared agent state and per-tool context with dynamic configuration.
Tool with needsApproval example
Example with boolean: bookFlight: tool({ description: 'Book a flight', inputSchema: z.object({ flightId: z.string(), passengerName: z.string() }), needsApproval: true, execute: bookFlightStep }). Example with async function: cancelBooking: tool({ description: 'Cancel a booking', inputSchema: z.object({ bookingId: z.string() }), needsApproval: async input => input.bookingId.startsWith('VIP-'), execute: cancelBookingStep }). The async form decides per-input whether approval is required.
WorkflowAgent structured output with Output
Parse agent responses into typed objects using Output.object(). Example: const result = await agent.stream({ messages, output: Output.object({ schema: z.object({ sentiment: z.enum(['positive', 'neutral', 'negative']), summary: z.string() }) }) }); result.output contains the typed output.
Tool with 'use step' example
Example: async function searchFlightsStep(input: { origin: string; destination: string; date: string }) { 'use step'; const response = await fetch(`https://api.flights.example/search?...`); return response.json(); } Marking the function with 'use step' makes it a durable workflow step with automatic retries, persistence, and observability.
WorkflowChatTransport initialization example
Example: const transport = useMemo(() => new WorkflowChatTransport({ api: '/api/chat', maxConsecutiveErrors: 5, initialStartIndex: -50 }), []); const { messages, sendMessage } = useChat({ transport }); This sets up resumable streaming with reconnection logic and fetches the last 50 chunks on page refresh.
WorkflowAgent loop control with stopWhen
Control agent steps using stopWhen parameter. isStepCount(10) stops after 10 LLM calls. isLoopFinished() lets the agent run until all tool calls have completed, but should be paired with maxSteps to avoid runaway loops. By default, the agent loops until the model stops calling tools with no maximum.
WorkflowAgent integration in workflow function
To use WorkflowAgent inside a workflow: (1) mark the function with 'use workflow', (2) convert UIMessage[] to ModelMessage[] using convertToModelMessages(), (3) pass getWritable<ModelCallStreamPart>() to agent.stream(), (4) return result.messages from the workflow function. At the response boundary, use createModelCallToUIChunkTransform() to convert ModelCallStreamPart chunks to UIMessageChunk for the client.
WorkflowAgent stream call example
Example usage: const result = await agent.stream({ messages: modelMessages, writable: getWritable<ModelCallStreamPart>() }); return { messages: result.messages }; This calls the agent's stream method with converted model messages and a writable stream, then returns the resulting messages.
WorkflowAgent example with flight booking
Example: 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 }) } }); This defines an agent with two tools for flight operations.
Tool needsApproval for WorkflowAgent
For WorkflowAgent, human approval is configured on the tool definition with needsApproval, which is specific to WorkflowAgent. For generateText, streamText, and ToolLoopAgent, use toolApproval instead. needsApproval can be a boolean (always require approval) or an async function that decides per-input whether approval is required. When needsApproval is set, the agent pauses, emits an approval request to the writable stream, and the workflow suspends until the user approves or denies. Because the workflow is durable, approval requests survive process restarts.