homeresume
 
   

Building AI agents with Vercel AI SDK

Published June 9, 2026Last updated June 8, 20268 min read

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:

  1. getCustomer - plan tier, open ticket count
  2. getInvoice - amount, status, payment IDs
  3. searchKnowledgeBase - duplicate-charge and refund policy
  4. 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 caseSuggested cap
Single tool, then answer2 (tool step + text step)
Chat with occasional tool use3-5
Task agents (triage, research)8-15
Long autonomous workflows15-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 (listPRsgetCheckssubmitReview) 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.