Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an AI Agent with the Vercel AI SDK

The Vercel AI SDK's ToolLoopAgent lets you build a provider-agnostic agent as a typed TypeScript process. Define tools with tool() and Zod, cap the loop with isStepCount, then gate the risky one behind toolApproval so it pauses instead of running blind. This guide builds a SaaS billing agent that looks up subscriptions freely and only issues an account credit over $50 once a human approves it. Budget 45-60 minutes.

What you'll learn

  • Build a ToolLoopAgent with typed tools defined via tool() and Zod schemas
  • Gate a billing-impacting tool behind toolApproval so it pauses for a human decision instead of running automatically
  • Write a dynamic approval policy function that auto-approves small credits and escalates larger ones
  • Cap the agent's tool-calling loop with isStepCount so a confused run fails fast instead of looping forever
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate11 min read45-60 minNode.js 20+, TypeScript, the `ai` package (v7), any OSLast updated November 2026RapidDev Engineering Team
TL;DR

The Vercel AI SDK's ToolLoopAgent lets you build a provider-agnostic agent as a typed TypeScript process. Define tools with tool() and Zod, cap the loop with isStepCount, then gate the risky one behind toolApproval so it pauses instead of running blind. This guide builds a SaaS billing agent that looks up subscriptions freely and only issues an account credit over $50 once a human approves it. Budget 45-60 minutes.

Quick facts about this guide
FactValue
ToolVercel AI SDK
DifficultyIntermediate
Time required45-60 min
CompatibilityNode.js 20+, TypeScript, the `ai` package (v7), any OS
Last updatedNovember 2026

What you'll build

You'll build a billing support agent for a SaaS product. It can look up a customer's subscription, usage, and next invoice through a lookupSubscription tool, and when a customer is owed money after an outage or a billing mistake, it can issue an account credit through issueAccountCredit. Credits under $50 post immediately. Anything larger pauses the entire run and waits for a human teammate to approve it before the credit actually lands on the account.

Under the hood, ToolLoopAgent (from the ai package) wraps a model, a set of tools, and a loop that keeps calling tools until it can answer. Tools are defined with tool() and a Zod inputSchema. The toolApproval option maps a tool name, or a policy function that inspects the call's input, to 'approved', 'denied', or 'user-approval'. When a call needs a human, agent.generate() returns a tool-approval-request part instead of executing; you collect a decision, push a ToolApprovalResponse back into the message array, and call generate() again to resume. stopWhen: isStepCount(n) caps how many rounds of tool calls the loop can take.

Pick the Vercel AI SDK when you're already in a TypeScript or Next.js stack and want one provider-agnostic agent you can point at Claude, GPT, or Gemini by changing a single model string, without adopting a separate agent framework. Choose the Claude Agent SDK instead when you specifically want Claude's own harness and built-in file/bash tools, or Mastra when you want a dedicated framework with built-in memory and durable workflows out of the box.

Prerequisites

  • Node.js 20+ and a TypeScript project (Next.js App Router or a plain script)
  • An Anthropic API key (ANTHROPIC_API_KEY), or another provider key the AI SDK supports
  • npm install ai zod
  • Basic comfort with async/await and Zod schemas

Step-by-step guide

1

Install the SDK and set your provider key

Install the ai package and zod, which you'll use to describe each tool's input shape. Export your model provider's API key as an environment variable instead of hardcoding it; the SDK reads it from the environment when you reference a model string like 'anthropic/claude-sonnet-5.5'. Keep it out of any file you might commit.

typescript
1npm install ai zod
2export ANTHROPIC_API_KEY="sk-ant-..."

Expected result: npm install finishes with no errors and `node -e "require('ai')"` resolves without throwing.

2

Define the read-only tool: lookupSubscription

Define lookupSubscription with tool(), a description the model reads to decide when to call it, a Zod inputSchema, and an execute function. It only reads data, so it needs no approval gate; this is the tool the agent calls on nearly every turn before it can say anything accurate about the account.

typescript
1import { tool } from 'ai';
2import { z } from 'zod';
3
4const ACCOUNTS: Record<string, { plan: string; usage: number; nextInvoice: number }> = {
5 'acct_8831': { plan: 'Team', usage: 92, nextInvoice: 240 },
6};
7
8const lookupSubscription = tool({
9 description: 'Look up a customer account by ID. Returns plan, usage percent, and next invoice amount.',
10 inputSchema: z.object({ accountId: z.string() }),
11 execute: async ({ accountId }) => {
12 const account = ACCOUNTS[accountId];
13 if (!account) return { error: `No account found for ${accountId}` };
14 return account;
15 },
16});

Expected result: A tool object you can pass straight into an agent's tools map, testable on its own by calling lookupSubscription.execute({ accountId: 'acct_8831' }).

3

Define the sensitive tool: issueAccountCredit

issueAccountCredit follows the same shape but takes accountId, amount, and reason. The execute function stays dumb: it just posts the credit when called. Whether it's allowed to run at all is decided one layer up, by the toolApproval policy you'll write next, not inside this function.

typescript
1const issueAccountCredit = tool({
2 description: 'Issue an account credit in dollars for billing errors or outages.',
3 inputSchema: z.object({
4 accountId: z.string(),
5 amount: z.number().positive(),
6 reason: z.string(),
7 }),
8 execute: async ({ accountId, amount, reason }) => {
9 // In production this calls your billing API, e.g. Stripe customer balance transactions
10 return { credited: amount, accountId, reason, postedAt: new Date().toISOString() };
11 },
12});

Expected result: A second tool, same shape as the first, ready to be added to the agent's tools map.

4

Build the ToolLoopAgent and wire a dynamic approval policy

ToolLoopAgent takes a model, the tools map, a toolApproval config, and stopWhen. The toolApproval entry for issueAccountCredit is a function, not a fixed string, so it can inspect the amount: credits of $50 or less return undefined (meaning 'not-applicable', run it), anything larger returns 'user-approval'. isStepCount(10) stops the loop after 10 rounds of tool calls so a confused agent fails fast instead of burning tokens.

typescript
1import { ToolLoopAgent, isStepCount } from 'ai';
2
3const agent = new ToolLoopAgent({
4 model: 'anthropic/claude-sonnet-5.5',
5 instructions:
6 'You are a billing support agent. Always call lookupSubscription before making any claim ' +
7 'about an account. Never tell a customer you issued a credit without actually calling issueAccountCredit.',
8 tools: { lookupSubscription, issueAccountCredit },
9 toolApproval: {
10 issueAccountCredit: async ({ amount }) => (amount > 50 ? 'user-approval' : undefined),
11 },
12 stopWhen: isStepCount(10),
13});

Expected result: An agent instance where small credits execute immediately and large ones return a tool-approval-request instead of running.

5

Run the agent and resolve the approval request

Call agent.generate() with the conversation so far. Walk result.content for parts with type 'tool-approval-request'; skip any with isAutomatic set to true, since those were already resolved by your policy. For the rest, collect a human decision, push a ToolApprovalResponse into the message array under a role of 'tool', and call generate() again to resume the paused run.

typescript
1import type { ModelMessage, ToolApprovalResponse } from 'ai';
2
3const messages: ModelMessage[] = [
4 { role: 'user', content: 'Account acct_8831 had a 6-hour outage last week, credit them $75.' },
5];
6
7const result = await agent.generate({ messages });
8messages.push(...result.responseMessages);
9
10const approvals: ToolApprovalResponse[] = [];
11for (const part of result.content) {
12 if (part.type === 'tool-approval-request' && !part.isAutomatic) {
13 console.log(`[APPROVAL NEEDED] $${part.input.amount} on ${part.input.accountId}: ${part.input.reason}`);
14 approvals.push({
15 type: 'tool-approval-response',
16 approvalId: part.approvalId,
17 approved: true,
18 reason: 'Confirmed outage in status page history',
19 });
20 }
21}
22
23if (approvals.length) {
24 messages.push({ role: 'tool', content: approvals });
25 const final = await agent.generate({ messages });
26 console.log(final.text);
27}

Expected result: The script prints an [APPROVAL NEEDED] line for the $75 credit, then a final confirmation message once the approval is resolved.

6

Harden it: real APIs, structured errors, and an audit log

Two changes separate this from a toy: swap the in-memory ACCOUNTS and the fake credit post for real calls to your billing API inside each execute function, wrapped in try/catch so a failure returns a structured error object instead of throwing (the model can react to that; an uncaught exception just crashes your process). Then log every tool-approval-request and the decision made on it to your observability stack before you push the ToolApprovalResponse, independent of whether it was auto-approved or decided by a human.

typescript
1const issueAccountCredit = tool({
2 description: 'Issue an account credit in dollars for billing errors or outages.',
3 inputSchema: z.object({ accountId: z.string(), amount: z.number().positive(), reason: z.string() }),
4 execute: async ({ accountId, amount, reason }) => {
5 try {
6 const result = await billingClient.createCreditBalanceTransaction({ accountId, amount, reason });
7 return { credited: amount, accountId, transactionId: result.id };
8 } catch (err) {
9 return { error: `Credit failed: ${(err as Error).message}` };
10 }
11 },
12});

Expected result: A handler that reports failures back to the model instead of crashing, and a log line for every approval decision, automatic or human.

Complete working example

billing-agent.ts
1/**
2 * Billing agent: looks up accounts freely, issues credits over $50 only after human approval.
3 * Run: npx tsx billing-agent.ts (npm install ai zod; set ANTHROPIC_API_KEY)
4 */
5import { ToolLoopAgent, tool, isStepCount } from 'ai';
6import type { ModelMessage, ToolApprovalResponse } from 'ai';
7import { z } from 'zod';
8
9// Mock account store - swap for a real billing API in production
10const ACCOUNTS: Record<string, { plan: string; usage: number; nextInvoice: number }> = {
11 acct_8831: { plan: 'Team', usage: 92, nextInvoice: 240 },
12 acct_9012: { plan: 'Starter', usage: 41, nextInvoice: 29 },
13};
14
15const lookupSubscription = tool({
16 description: 'Look up a customer account by ID. Returns plan, usage percent, and next invoice amount.',
17 inputSchema: z.object({ accountId: z.string() }),
18 execute: async ({ accountId }) => {
19 const account = ACCOUNTS[accountId];
20 if (!account) return { error: `No account found for ${accountId}` };
21 return account;
22 },
23});
24
25const issueAccountCredit = tool({
26 description: 'Issue an account credit in dollars for billing errors or outages.',
27 inputSchema: z.object({
28 accountId: z.string(),
29 amount: z.number().positive(),
30 reason: z.string(),
31 }),
32 execute: async ({ accountId, amount, reason }) => {
33 // In production this calls your billing API, e.g. Stripe customer balance transactions
34 return { credited: amount, accountId, reason, postedAt: new Date().toISOString() };
35 },
36});
37
38const agent = new ToolLoopAgent({
39 model: 'anthropic/claude-sonnet-5.5',
40 instructions:
41 'You are a billing support agent. Always call lookupSubscription before making any claim ' +
42 'about an account. Never tell a customer you issued a credit without actually calling issueAccountCredit.',
43 tools: { lookupSubscription, issueAccountCredit },
44 toolApproval: {
45 issueAccountCredit: async ({ amount }) => (amount > 50 ? 'user-approval' : undefined),
46 },
47 stopWhen: isStepCount(10),
48});
49
50async function main() {
51 const messages: ModelMessage[] = [
52 { role: 'user', content: 'Account acct_8831 had a 6-hour outage last week, credit them $75.' },
53 ];
54
55 const result = await agent.generate({ messages });
56 messages.push(...result.responseMessages);
57
58 const approvals: ToolApprovalResponse[] = [];
59 for (const part of result.content) {
60 if (part.type === 'tool-approval-request' && !part.isAutomatic) {
61 console.log(`\n[APPROVAL NEEDED] $${part.input.amount} on ${part.input.accountId}: ${part.input.reason}`);
62 approvals.push({
63 type: 'tool-approval-response',
64 approvalId: part.approvalId,
65 approved: true,
66 reason: 'Confirmed outage in status page history',
67 });
68 }
69 }
70
71 if (approvals.length > 0) {
72 messages.push({ role: 'tool', content: approvals });
73 const final = await agent.generate({ messages });
74 console.log(`\nAgent: ${final.text}`);
75 } else {
76 console.log(`\nAgent: ${result.text}`);
77 }
78}
79
80main();
81

Common mistakes

Why it's a problem: Omitting stopWhen and letting a confused agent loop through tool calls indefinitely

How to avoid: Always set stopWhen: isStepCount(n). It's the budget cap that turns a stuck agent into a fast, cheap failure instead of a runaway bill.

Why it's a problem: Setting toolApproval to the fixed string 'approved' on a write tool 'just for testing' and forgetting to revert it

How to avoid: Keep write tools on a policy function or 'user-approval' permanently. Test the approval pause-and-resume flow directly instead of bypassing it.

Why it's a problem: Treating result.content as final the moment a tool-approval-request part appears

How to avoid: A tool-approval-request means the run paused, not finished. Collect decisions, push a ToolApprovalResponse, and call generate() again to get the real result.

Why it's a problem: Prompting a human for every tool-approval-request without checking isAutomatic

How to avoid: Parts with isAutomatic: true were already resolved by your policy function. Only surface the ones where isAutomatic is false or missing.

Why it's a problem: Putting the approval decision logic inside a tool's execute() function

How to avoid: execute() should stay dumb and assume it's allowed to run. All gating belongs in toolApproval, so the pattern stays reusable for the next write tool you add.

Best practices

  • Default every tool that writes data or moves money to toolApproval; start at 'user-approval' and loosen with a policy function once you trust the pattern.
  • Key approval policy functions on the tool's actual input (amount, role, account tier), not a blanket per-tool rule.
  • Always set stopWhen: isStepCount(n); the SDK's isLoopFinished() removes the cap entirely and that's a cost risk, not a convenience.
  • Keep execute() functions dumb and put all approval logic in toolApproval so new write tools reuse the same gate.
  • Log every tool-approval-request and its resolution, including auto-approved ones, before you resume the run.
  • State the tool-use rule explicitly in instructions: never claim a credit was issued without actually calling the tool.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a Vercel AI SDK ToolLoopAgent with lookupSubscription and issueAccountCredit tools, the second gated by a toolApproval policy function on amount (code below). Add a third tool, checkRefundEligibility, that must pass before issueAccountCredit can run, and make the policy also require approval when the same account has had 2+ credits this month. [paste complete_code]

Build AI Agents Prompt

In Cursor, open billing-agent.ts and ask: 'Add a revokeAccountCredit tool with the same Zod input shape and toolApproval gating pattern as issueAccountCredit, and update the instructions to mention when to use it.'

Frequently asked questions

What's the difference between the Vercel AI SDK and a framework like LangChain or the Claude Agent SDK?

The Vercel AI SDK is a thinner, provider-agnostic layer: ToolLoopAgent gives you the tool-calling loop, approvals, and step limits, but no built-in memory store, multi-agent orchestration, or vendor-specific harness. LangChain and the Claude Agent SDK bundle more of that for you; the AI SDK trades some of it for staying close to plain TypeScript and being easy to swap models in.

How do I build an AI agent with the Vercel AI SDK if I've only used the plain generateText function before?

generateText makes one model call; ToolLoopAgent wraps the whole loop of calling tools, feeding results back, and deciding when to stop. Start from this guide's pattern: define tools with tool() and Zod, pass them into a ToolLoopAgent alongside a model string and stopWhen, then call agent.generate() instead of generateText().

What happened to stepCountIs and maxSteps in AI SDK v7?

Both still work as deprecated aliases, but v7's recommended name is isStepCount, used with stopWhen. The SDK also renamed system to instructions and onFinish to onEnd. New code should use the v7 names; old code using the aliases isn't broken, just stale.

Does toolApproval work the same way for every tool, or can I mix policies?

You can mix freely. toolApproval accepts a fixed string per tool name ('approved', 'denied', 'user-approval'), a function per tool name that inspects that call's input, or one generic function keyed on toolCall.toolName that covers every tool in a single rule.

How much does a conversation like this cost in tokens?

On Claude Sonnet 5.5 ($2/M input, $10/M output), a short billing exchange with one tool call and a few thousand tokens of instructions and history runs about $0.01-0.03 per turn uncached, and noticeably less once your instructions and tool schemas are stable enough to benefit from prompt caching.

Can someone build this for me instead of doing it myself?

Yes. RapidDev builds production agents on the Vercel AI SDK, the Claude Agent SDK, and Mastra, including the approval and audit-trail pieces most tutorials skip. A free 30-minute consultation is the fastest way to find out what your version would take.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.