The Vercel AI SDK treats agents as tool-calling loops: the model generates text or invokes tools, the SDK runs those tools, and the loop continues until the model answers or a stop condition fires.
This post builds a support triage agent that looks up customers and invoices, searches an internal knowledge base, and either opens a ticket or escalates to a human. It builds on the LLM integration with Vercel AI SDK post and focuses on multiple tools, stopWhen, and stepCountIs.
For external tools exposed over MCP instead of SDK-native tool() handlers, see the MCP server with Node.js post.
Prerequisites
- OpenAI account
- Generated API key
- Enabled billing
- Node.js version 26
-
ai, @ai-sdk/openai, and zod installed (npm i ai @ai-sdk/openai zod)
- Client setup from the Vercel AI SDK integration post
Mental model – steps and the tool loop
A step is one model generation. In that step the model either:
- returns text (the loop ends), or
- returns tool calls (the SDK executes them and starts another step with the results)
Typical flow for the support triage agent: user question → model calls lookup tools (getCustomer, getInvoice, searchKnowledgeBase) → model creates a ticket or escalates → final answer. stopWhen can end the loop before or after the write tools run.
stepCountIs(5) means “stop after 5 steps” (five model generations), not five individual tool calls. A single step can include multiple parallel tool calls.
When you pass tools without stopWhen, the SDK defaults to stepCountIs(20) as a safety cap.
Support triage scenario
Example prompt:
Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?
A realistic chain:
-
getCustomer – plan tier, open ticket count
-
getInvoice – amount, status, payment IDs
-
searchKnowledgeBase – duplicate-charge and refund policy
-
createSupportTicket or escalateToHuman – write action or sentinel stop
The demo uses in-memory fixtures (customers, invoices, knowledge-base articles) so scripts run without a database.
Defining multiple tools
Register tools with tool() and Zod inputSchema. Clear description values help the model pick the right tool.
import { tool } from 'ai';
import { z } from 'zod';
const getCustomer = tool({
description: 'Look up a customer account by ID',
inputSchema: z.object({
customerId: z.string().describe('Customer ID, e.g. cus_1042'),
}),
execute: async ({ customerId }) => {
const customer = customers.find((item) => item.id === customerId);
if (!customer) {
return { found: false, customerId, error: 'Customer not found' };
}
return { found: true, customer };
},
});
const getInvoice = tool({
description: 'Look up an invoice by ID, including payment IDs and status',
inputSchema: z.object({
invoiceId: z.string().describe('Invoice ID, e.g. inv_8891'),
}),
execute: async ({ invoiceId }) => {
const invoice = invoices.find((item) => item.id === invoiceId);
if (!invoice) {
return { found: false, invoiceId, error: 'Invoice not found' };
}
return { found: true, invoice };
},
});
const searchKnowledgeBase = tool({
description: 'Search internal support articles by keyword',
inputSchema: z.object({
query: z.string().describe('Search terms, e.g. duplicate charge refund'),
}),
execute: async ({ query }) => {
// keyword match against mocked articles
return { query, articles: matches };
},
});
Add write tools for outcomes:
const createSupportTicket = tool({
description: 'Create a support ticket after gathering customer and policy context',
inputSchema: z.object({
customerId: z.string(),
subject: z.string().min(3),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string().min(10),
}),
execute: async (input) => {
const ticket = createTicket(input);
return { created: true, ticket };
},
});
const escalateToHuman = tool({
description: 'Escalate when policy requires manual review',
inputSchema: z.object({
customerId: z.string(),
reason: z.string().min(10),
urgency: z.enum(['normal', 'high']),
}),
execute: async (input) => ({
escalated: true,
queue: input.urgency === 'high' ? 'billing-urgent' : 'billing-standard',
...input,
}),
});
Return structured objects from execute. The SDK serializes them as tool results for the next step. Return explicit errors (for example { found: false, error: '...' }) so the model can recover instead of throwing.
Multi-step triage with generateText
Pass all tools and a system prompt with triage rules:
import { generateText, stepCountIs } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: `You are a billing support triage agent.
- Look up customer and invoice before recommending refunds.
- Search the knowledge base for policy guidance.
- Create a ticket when you can resolve within policy.
- Call escalateToHuman when manual review is required.`,
tools: {
getCustomer,
getInvoice,
searchKnowledgeBase,
createSupportTicket,
escalateToHuman,
},
stopWhen: stepCountIs(8),
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
});
console.log('steps:', steps.length);
console.log(text);
Use a model that supports tool calling (same requirement as web search in the Vercel AI SDK post).
stopWhen – when the loop stops
stopWhen defines stopping conditions for the tool loop. Conditions are evaluated only when the last step contains tool results.
- A single condition stops when that condition returns
true
- An array stops when any condition returns
true (OR logic)
- Without
stopWhen, the SDK applies stepCountIs(20)
The loop also ends naturally when the model returns text without further tool calls.
stepCountIs – cap the number of steps
stepCountIs(n) stops once steps.length reaches n. Use it on every production agent to prevent runaway loops and unbounded API cost.
| Use case |
Suggested cap |
| Single tool, then answer |
2 (tool step + text step) |
| Chat with occasional tool use |
3-5 |
| Task agents (triage, research) |
8-15 |
| Long autonomous workflows |
15-20 (with monitoring) |
Tight vs relaxed cap on the same prompt:
import { generateText, stepCountIs } from 'ai';
// Stops after 3 steps even if the model still wants more context
const capped = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(3),
prompt: '...',
});
// Allows a fuller investigation chain
const relaxed = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});
Combining hasToolCall with stepCountIs
hasToolCall('toolName') stops when the model invokes a specific tool in the latest step. Pair it with stepCountIs for a hard cap plus a sentinel tool:
import { generateText, stepCountIs, hasToolCall } from 'ai';
const { text, steps } = await generateText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: [stepCountIs(10), hasToolCall('escalateToHuman')],
prompt:
'Customer cus_2201 on the starter plan reports a duplicate $190 charge on invoice inv_9104.',
});
escalateToHuman works well as a sentinel: the loop stops as soon as the model decides the case needs a human, without waiting for a final text-only step.
Inspecting steps and usage
The steps array on the result contains per-step tool calls, tool results, finish reason, and usage. Use it for debugging and cost tracking:
const { text, steps, totalUsage } = await generateText({
model: openai('gpt-5.5'),
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: '...',
});
for (const [index, step] of steps.entries()) {
console.log(`step ${index + 1}`);
console.log(' toolCalls:', step.toolCalls?.map((c) => c.toolName));
console.log(' usage:', step.usage);
}
console.log('totalUsage:', totalUsage);
With streamText, pass onStepFinish to log each step as it completes.
ToolLoopAgent – reusable agent definition
ToolLoopAgent wraps the same loop for reuse across scripts and API routes. It accepts the same settings as generateText (tools, stopWhen, instructions).
import { ToolLoopAgent, stepCountIs } from 'ai';
const supportTriageAgent = new ToolLoopAgent({
model: openai('gpt-5.5'),
instructions: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
});
const result = await supportTriageAgent.generate({
prompt:
'Customer cus_1042 says they were charged twice for invoice inv_8891. What should we do?',
onStepFinish: async ({ stepNumber, usage, toolCalls }) => {
console.log(`step ${stepNumber + 1}`, {
tokens: usage.totalTokens,
tools: toolCalls?.map((call) => call.toolName),
});
},
});
console.log(result.text);
Use .stream() for streaming. For Next.js chat UIs, see createAgentUIStreamResponse in the AI SDK agents docs.
Streaming with tools
streamText supports the same tools and stopWhen settings:
import { streamText, stepCountIs } from 'ai';
const result = streamText({
model: openai('gpt-5.5'),
system: TRIAGE_INSTRUCTIONS,
tools: supportTools,
stopWhen: stepCountIs(8),
prompt: 'Customer cus_1042 says they were charged twice for invoice inv_8891.',
onStepFinish: async ({ stepNumber, toolCalls }) => {
console.error(`step ${stepNumber + 1}:`, toolCalls?.map((c) => c.toolName));
},
});
for await (const part of result.textStream) {
process.stdout.write(part);
}
Text streams incrementally. Tool calls run between text segments as the loop progresses.
Production notes
-
Always set
stopWhen – do not rely on the default stepCountIs(20) in production without monitoring
-
Cost – each step is another model call; log
steps or onStepFinish usage
-
Tool errors – return structured errors from
execute instead of throwing when the model should retry or escalate
-
Instructions – keep policy rules in
system / instructions, not only in the user prompt
-
Same patterns elsewhere – PR review (
listPRs → getChecks → submitReview) or job-fit scoring use the same loop mechanics with different tools
Demo
Runnable scripts for each section live in the vercel-ai-sdk-agents-demo folder. Get access via code demos.