Building AI agents with Vercel AI SDK
June 9, 2026The 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, andzodinstalled (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 countgetInvoice- amount, status, payment IDssearchKnowledgeBase- duplicate-charge and refund policycreateSupportTicketorescalateToHuman- 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 articlesreturn { 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 appliesstepCountIs(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 contextconst capped = await generateText({model: openai('gpt-5.5'),tools: supportTools,stopWhen: stepCountIs(3),prompt: '...',});// Allows a fuller investigation chainconst 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 defaultstepCountIs(20)in production without monitoring - Cost - each step is another model call; log
stepsoronStepFinishusage - Tool errors - return structured errors from
executeinstead 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.