AI SDK 6 gives a TypeScript application a clear boundary between model decisions and application authority. The model can select a typed tool, receive its result, and continue the conversation. Your code still validates inputs, enforces permissions, decides whether a side effect needs a person, and records what happened. That separation is the useful design principle behind a production agent. This guide uses the stable AI SDK 6 APIs, not the later approval API introduced in AI SDK 7. Install ai@6 together with a provider package compatible with the 6.x line, such as @ai-sdk/openai@3, and pin versions in an application lockfile.
The loop that you are building
A single model call can return text or a tool call. A tool loop adds a second model call after the tool has finished, so the model can interpret the result and decide whether another tool is needed. Each model generation is a step. A loop ends when the model stops requesting tools, a tool has no execute function, approval is needed, or a stopWhen condition becomes true. The result exposes the final text, tool calls, tool results, response messages, usage, and steps, which makes the loop inspectable instead of magical.
ToolLoopAgent packages this behavior as a reusable object. Its constructor requires a LanguageModel and accepts instructions, tools, stopWhen, output, prepareStep, maxRetries, timeouts, and callbacks. In AI SDK 6 the default stop condition is stepCountIs(20). Set a smaller limit for a bounded workflow. The limit is a cost and availability control, not a permission system.
A typed ToolLoopAgent
The tool helper derives the execute function's input type from inputSchema. The schema is sent to the provider and used to validate the model's arguments before execution. The model still does not become trusted because a valid shape can contain an unauthorized account, path, URL, or amount.
npm install [email protected] @ai-sdk/[email protected] @ai-sdk/[email protected] zod
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs, tool } from 'ai';
import { z } from 'zod';
const getWeather = tool({
description: 'Get the current weather for a city',
inputSchema: z.object({
city: z.string().min(1).max(80),
}),
execute: async ({ city }) => ({
city,
temperatureC: 18,
condition: 'cloudy',
}),
});
const agent = new ToolLoopAgent({
model: openai('gpt-4o-mini'),
instructions: 'Use getWeather for current weather. Never invent a tool result.',
tools: { getWeather },
stopWhen: stepCountIs(4),
maxRetries: 2,
});
const result = await agent.generate({
prompt: 'What is the weather in Kyiv?',
});
console.log(result.text);
console.log(result.steps.length);
A tool should do one operation and return a small, serializable result. Keep descriptions precise about units, permissions, freshness, and failure behavior. Use strict: true when the provider supports strict tool calls and the schema is compatible. Treat strict mode as an additional reliability measure, not as authorization. toolChoice: 'auto' lets the model decide, required forces one of the available tools, none disables tools, and { type: 'tool', toolName: 'getWeather' } selects one named tool. activeTools can narrow the tools exposed for a particular step.
Explicit calls and loop limits
Use generateText when you want a one-off call or direct control over the message history. Add stopWhen if tool results must be sent back to the model. Without it, generateText performs one generation and returns a tool call without automatically continuing.
import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs, tool } from 'ai';
import { z } from 'zod';
const lookupOrder = tool({
description: 'Look up an order by its public order number',
inputSchema: z.object({ orderNumber: z.string().regex(/^ORD-[0-9]{6}$/) }),
execute: async ({ orderNumber }) => ({
orderNumber,
status: 'shipped',
}),
});
const result = await generateText({
model: openai('gpt-4o-mini'),
tools: { lookupOrder },
stopWhen: stepCountIs(3),
prompt: 'Check order ORD-104209 and explain its status.',
});
console.log(result.text);
console.log(result.steps.flatMap(step => step.toolCalls));
The built-in conditions are stepCountIs(count), hasToolCall(toolName), and isLoopFinished(). An array stops when any condition matches. isLoopFinished() has no maximum, so use it only with an external budget, timeout, cancellation signal, and provider quota. A custom StopCondition receives { steps }; it can stop on a business state or a measured token budget. Remember that conditions are evaluated when the last step contains tool results. If you combine tool calling with structured output, the output generation consumes an additional step, so reserve room for it.
maxRetries retries failed model calls. It does not make an execute function idempotent. A tool that sends an email, charges a card, or creates a record must carry an idempotency key and perform its own duplicate check. For a remote MCP client, retries are opt-in and use maxRetries on createMCPClient. Retry network and rate-limit failures only. Do not blindly retry a non-idempotent tools/call request.
Structured output that TypeScript can trust
AI SDK 6 deprecates generateObject and streamObject in favor of generateText and streamText with an output specification. Output.object accepts a Zod, Valibot, or JSON schema. The complete response is parsed and validated before result.output resolves. Partial streamed objects are useful for a UI, but partial values are not a final validation decision.
import { openai } from '@ai-sdk/openai';
import { generateText, Output } from 'ai';
import { z } from 'zod';
const reportSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
score: z.number().min(0).max(1),
keyPoints: z.array(z.string().min(1)).max(8),
});
const { output } = await generateText({
model: openai('gpt-4o-mini'),
output: Output.object({
schema: reportSchema,
name: 'review_report',
description: 'A concise, evidence-based review report',
}),
prompt: 'Analyze: The battery lasts all day, but the charger is bulky.',
});
console.log(output.sentiment, output.score, output.keyPoints);
Validation has two stages. The provider may enforce a response format, and AI SDK parses the returned JSON and validates it against the schema. Your application must still check business rules such as whether an identifier belongs to the current tenant or a score is allowed to trigger a refund. Keep schemas closed and narrow. Bound strings, arrays, numbers, and enum values. Reject unknown commands rather than passing arbitrary JSON into a privileged adapter.
With tools, the model can first call a lookup tool and then produce the report. Set stopWhen: stepCountIs(4) or another explicit budget because the structured output step is part of the same multi-step flow. If parsing fails, catch the SDK error, preserve its correlation ID and provider metadata, and return a safe retryable response. Do not ask a second model to authorize malformed output.
Streaming text, tool events, and output
streamText exposes asynchronous streams for text and structured output. ToolLoopAgent.stream returns a StreamTextResult after the agent has prepared the call, so await it before reading textStream. The text stream contains generated text, while the full result and callbacks expose tool calls and results.
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent } from 'ai';
const agent = new ToolLoopAgent({
model: openai('gpt-4o-mini'),
instructions: 'Answer clearly and briefly.',
});
const stream = await agent.stream({
prompt: 'Explain why typed tool inputs matter.',
});
for await (const chunk of stream.textStream) {
process.stdout.write(chunk);
}
For a structured stream, use streamText with Output.object and consume partialOutputStream. Use onStepFinish to persist a completed step, record usage, and show an audit event. The stream can end with tool-approval-request, tool-error, or tool-output-denied parts, not only text. A client that renders only text can hide an action that is waiting for a person.
Human approval for side effects
In AI SDK 6, set needsApproval on a tool, either to true or to an async predicate based on validated input. The first generateText or streamText call returns a tool-approval-request part. It does not pause a server and wait for a browser. Store the response messages, display the exact tool name and arguments, then add a tool-approval-response to a new tool message and call the model again.
The following complete Node example uses a terminal prompt as the human boundary. A web application would persist messages, approvalId, toolCallId, the authenticated reviewer, and an expiry time in a server-side store.
import { createInterface } from 'node:readline/promises';
import { openai } from '@ai-sdk/openai';
import {
generateText,
tool,
type ModelMessage,
type ToolApprovalResponse,
} from 'ai';
import { z } from 'zod';
const records = new Map([
['draft-17', { ownerId: 'user-7', text: 'Quarterly notes' }],
]);
const deleteDraft = tool({
description: 'Delete one draft owned by the authenticated user',
inputSchema: z.object({ draftId: z.string().regex(/^draft-[0-9]+$/) }),
needsApproval: true,
execute: async ({ draftId }) => {
if (!records.delete(draftId)) {
throw new Error('Draft was not found');
}
return { draftId, deleted: true };
},
});
async function requestHumanApproval(toolName: string, input: unknown) {
const terminal = createInterface({ input: process.stdin, output: process.stdout });
const answer = await terminal.question(`Approve ${toolName} ${JSON.stringify(input)}? [y/N] `);
terminal.close();
return answer.trim().toLowerCase() === 'y';
}
const messages: ModelMessage[] = [
{ role: 'user', content: 'Delete draft-17.' },
];
const first = await generateText({
model: openai('gpt-4o-mini'),
system: 'If an action is denied, do not retry it.',
tools: { deleteDraft },
messages,
});
messages.push(...first.response.messages);
const approvals: ToolApprovalResponse[] = [];
for (const part of first.content) {
if (part.type === 'tool-approval-request') {
approvals.push({
type: 'tool-approval-response',
approvalId: part.approvalId,
approved: await requestHumanApproval(part.toolCall.toolName, part.toolCall.input),
reason: 'Decision made by the authenticated reviewer',
});
}
}
if (approvals.length > 0) {
messages.push({ role: 'tool', content: approvals });
const final = await generateText({
model: openai('gpt-4o-mini'),
system: 'If an action is denied, do not retry it.',
tools: { deleteDraft },
messages,
});
console.log(final.text);
} else {
console.log(first.text);
}
Approval is not a replacement for authorization. Re-check the current user, tenant, target, policy, and resource version immediately before the side effect. Bind a stored approval to the approvalId, toolCallId, tool name, and a hash of the validated input. Expire it, allow one use, and reject a changed input. A denial should become a clear tool result and should not be retried automatically. For high-impact actions, show target, identity, arguments, data leaving the system, and expected side effects instead of showing only the model's summary.
Dangerous tools and security boundaries
Do not expose a general shell, unrestricted HTTP client, arbitrary file path, or database connection as a model tool. Prefer narrow operations such as deleteDraft, createCalendarEvent, or lookupOrder. Put authorization and allowlists in the execute function or a policy service. Validate URLs by scheme, host, port, resolved address, and redirect. Resolve paths inside an allowed root and account for symlinks. Enforce tenant ownership in the database query, not only in the prompt.
Treat tool descriptions, retrieved documents, memory, and tool results as untrusted content. A prompt injection can ask a model to reveal a secret, call an unrelated tool, or send data to an attacker-controlled URL. The model is not a security boundary. Apply the least privilege, provenance, egress, isolation, rate, and approval controls described in Prompt injection and MCP security. The production AI agent architecture guide is useful for separating planner, executor, policy, and storage responsibilities.
Never place API keys in prompts or tool results. Redact tokens in logs and telemetry. Do not log full tool inputs when they can contain personal data. Use request IDs across model calls and tool executions. Limit output size before it enters another prompt. Keep separate credentials for read, draft, and commit actions. For a browser or file agent, use an isolated worker with no unrelated credentials and a denied-by-default network policy.
MCP tools without losing type safety
The @ai-sdk/mcp package adapts an MCP server's tools into AI SDK tools. AI SDK 6 recommends HTTP transport for production and stdio for local servers. Define schemas explicitly when a server is outside your control or a tool is sensitive. This keeps the tool set narrow and gives TypeScript useful input types. Set redirect: 'error' when redirects are not part of the deployment policy, validate OAuth authorization-server origins, and close the client in finally or onFinish.
import { openai } from '@ai-sdk/openai';
import { createMCPClient } from '@ai-sdk/mcp';
import { ToolLoopAgent, stepCountIs } from 'ai';
import { z } from 'zod';
const mcpUrl = process.env.MCP_URL;
if (!mcpUrl) throw new Error('MCP_URL is required');
const mcpClient = await createMCPClient({
transport: {
type: 'http',
url: mcpUrl,
headers: process.env.MCP_TOKEN
? { Authorization: `Bearer ${process.env.MCP_TOKEN}` }
: undefined,
redirect: 'error',
},
maxRetries: 2,
});
try {
const tools = await mcpClient.tools({
schemas: {
'get-customer-note': {
inputSchema: z.object({ customerId: z.string().uuid() }),
},
},
});
const agent = new ToolLoopAgent({
model: openai('gpt-4o-mini'),
instructions: 'Use only the customer note tool for this request.',
tools,
stopWhen: stepCountIs(3),
});
const result = await agent.generate({ prompt: 'Read the requested customer note.' });
console.log(result.text);
} finally {
await mcpClient.close();
}
MCP schema discovery with mcpClient.tools() is convenient but exposes every advertised tool and does not provide compile-time input types. Explicit schemas pull only named tools. Retry only transient network failures. MCP application errors and successful responses marked isError: true should be surfaced without replaying side effects. OAuth still needs audience checks, PKCE, short-lived tokens, exact redirect URIs, and an allowlist for discovered authorization servers. See TypeScript MCP server with OAuth for the server-side contract.
Errors, tests, and operations
Wrap the whole generation in a boundary that distinguishes invalid input, provider failure, timeout, cancellation, malformed output, and tool failure. AI SDK converts an exception from execute into a tool-error part so a multi-step model call can observe the failure. Return a sanitized error string from a tool or map the result before it reaches the model. Do not reveal stack traces, credentials, SQL, filesystem paths, or upstream response bodies. Use abortSignal and timeout to bound every request. Record finishReason, usage, totalUsage, steps.length, and the tool policy decision.
AI SDK 6 includes deterministic mocks in ai/test. MockLanguageModelV3 lets a test return a tool call on the first generation and text on the second. The following test proves that the loop executes the tool once and makes the final result from the tool response without contacting a provider.
import assert from 'node:assert/strict';
import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { z } from 'zod';
const usage = {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
};
let calls = 0;
let executions = 0;
const model = new MockLanguageModelV3({
doGenerate: async () => {
calls += 1;
if (calls === 1) {
return {
content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'add', input: '{"a":2,"b":3}' }],
finishReason: { unified: 'tool-calls', raw: undefined },
usage,
warnings: [],
};
}
return {
content: [{ type: 'text', text: 'The result is 5.' }],
finishReason: { unified: 'stop', raw: undefined },
usage,
warnings: [],
};
},
});
const add = tool({
description: 'Add two numbers',
inputSchema: z.object({ a: z.number(), b: z.number() }),
execute: async ({ a, b }) => {
executions += 1;
return a + b;
},
});
const result = await generateText({
model,
tools: { add },
stopWhen: stepCountIs(2),
prompt: 'Add 2 and 3.',
});
assert.equal(result.text, 'The result is 5.');
assert.equal(calls, 2);
assert.equal(executions, 1);
Add tests for rejected schemas, tenant mismatches, path traversal, SSRF addresses, duplicate idempotency keys, approval expiry, denial behavior, MCP tool allowlists, and output validation. Test the stream's tool and approval parts, not only the visible text. Run adversarial cases for documents that instruct the model to ignore the task or leak context. The AI agent evaluations guide covers regression datasets, tool-call assertions, and cost and latency measurements.
Migration notes from AI SDK 5
Replace Experimental_Agent with ToolLoopAgent. Rename its system setting to instructions. The default stop condition changes from stepCountIs(1) to stepCountIs(20), so an upgrade can now make more model calls unless you set an explicit limit. Replace generateObject and streamObject with generateText and streamText plus Output.object, Output.array, or another output strategy. The streaming result uses partialOutputStream.
CoreMessage becomes ModelMessage, and convertToModelMessages is asynchronous in AI SDK 6. ToolCallOptions becomes ToolExecutionOptions. Mock V2 classes become MockLanguageModelV3 and the other V3 mocks from ai/test. The provider option structuredOutputs was removed from chat models in favor of strictJsonSchema. When implementing toModelOutput, destructure the { output } argument as required by the v6 signature. Run the v6 codemod, then inspect every provider adapter, message conversion, approval replay, and stream test by hand. A codemod can rename symbols, but it cannot decide whether a changed default is safe for your workflow.
The exact API surface depends on the pinned patch version and provider. Before upgrading, read the version-matched ToolLoopAgent reference, tool calling guide, structured data guide, MCP guide, testing guide, and AI SDK 5 to 6 migration guide. Treat provider warnings as test failures when an option is ignored.