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.
| Fact | Value |
|---|---|
| Tool | Vercel AI SDK |
| Difficulty | Intermediate |
| Time required | 45-60 min |
| Compatibility | Node.js 20+, TypeScript, the `ai` package (v7), any OS |
| Last updated | November 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
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.
1npm install ai zod2export ANTHROPIC_API_KEY="sk-ant-..."Expected result: npm install finishes with no errors and `node -e "require('ai')"` resolves without throwing.
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.
1import { tool } from 'ai';2import { z } from 'zod';34const ACCOUNTS: Record<string, { plan: string; usage: number; nextInvoice: number }> = {5 'acct_8831': { plan: 'Team', usage: 92, nextInvoice: 240 },6};78const 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' }).
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.
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 transactions10 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.
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.
1import { ToolLoopAgent, isStepCount } from 'ai';23const 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.
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.
1import type { ModelMessage, ToolApprovalResponse } from 'ai';23const messages: ModelMessage[] = [4 { role: 'user', content: 'Account acct_8831 had a 6-hour outage last week, credit them $75.' },5];67const result = await agent.generate({ messages });8messages.push(...result.responseMessages);910const 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}2223if (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.
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.
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
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';89// Mock account store - swap for a real billing API in production10const 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};1415const 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});2425const 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 transactions34 return { credited: amount, accountId, reason, postedAt: new Date().toISOString() };35 },36});3738const 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});4950async function main() {51 const messages: ModelMessage[] = [52 { role: 'user', content: 'Account acct_8831 had a 6-hour outage last week, credit them $75.' },53 ];5455 const result = await agent.generate({ messages });56 messages.push(...result.responseMessages);5758 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 }7071 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}7980main();81Common 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.
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]
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.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation