Tool Calling with the AI SDK in Next.js: Build an Assistant That Actually Does Things

A chat box that only answers questions is a demo. The moment your AI feature has to look something like a user's orders, book a slot, or update a record, you need tool calling - letting the model call functions you define, read the results, and keep going until the task is done. This guide builds that loop with the Vercel AI SDK v6 in Next.js, and it is shorter than you would expect. You give the model a set of tools - each one a description, an input schema, and a function to run. The model dec
A chat box that only answers questions is a demo. The moment your AI feature has to look something like a user's orders, book a slot, or update a record, you need tool calling - letting the model call functions you define, read the results, and keep going until the task is done. This guide builds that loop with the Vercel AI SDK v6 in Next.js, and it is shorter than you would expect.
The mental model
You give the model a set of tools - each one a description, an input schema, and a function to run. The model decides which tools to call and with what arguments; the SDK runs your functions, feeds the results back, and loops until the model produces a final answer. You never write the orchestration loop by hand.
Setup: one package, gateway model strings
Install the ai package. For the model, pass a plain provider/model string and it routes through the Vercel AI Gateway automatically - no provider SDK, no API key juggling, and you can swap models by changing the string. Gateway slugs use dots for versions (anthropic/claude-sonnet-4.6), which is the one naming gotcha to remember.
// app/api/chat/route.ts
import { generateText } from 'ai'
export async function POST(req: Request) {
const { prompt } = await req.json()
const result = await generateText({
model: 'anthropic/claude-sonnet-4.6', // routes through AI Gateway
prompt,
})
return Response.json({ text: result.text })
}
Enter fullscreen mode Exit fullscreen mode
Defining a tool
A tool is a description (so the model knows when to reach for it), an inputSchema built with Zod (so arguments are validated and typed), and an execute function (your actual code). Be prescriptive in the description about when to call it - not just what it does.
import { tool } from 'ai'
import { z } from 'zod'
const getOrders = tool({
description:
"Look up a customer's recent orders. Call this whenever the user asks about order status, history, or a specific purchase.",
inputSchema: z.object({
customerId: z.string().describe('The customer id from the session'),
limit: z.number().optional().describe('How many orders to return'),
}),
execute: async ({ customerId, limit = 5 }) => {
const orders = await db.orders.findMany({
where: { customerId },
take: limit,
orderBy: { createdAt: 'desc' },
})
return orders
},
})
Enter fullscreen mode Exit fullscreen mode
In AI SDK v6 the field is
inputSchema, notparameters. If you are following an older tutorial that usesparameters, it will not typecheck - the rename is the single most common upgrade error.
The multi-step loop
One tool call is rarely the whole task. The model might call getOrders, read the result, then decide it also needs to check inventory before answering. stopWhen lets the SDK run that back-and-forth automatically - it keeps looping (call tool, feed result back, let the model continue) until the model stops calling tools or you hit the step cap.
import { generateText, stepCountIs } from 'ai'
const result = await generateText({
model: 'anthropic/claude-sonnet-4.6',
stopWhen: stepCountIs(5), // cap the loop at 5 steps
tools: { getOrders, checkInventory },
prompt: 'Where is my last order and is a replacement in stock?',
})
// result.text is the final answer after all tool calls resolved
console.log(result.text)
Enter fullscreen mode Exit fullscreen mode
That stepCountIs(5) is your safety belt. Without a cap, a confused model could loop indefinitely. Five is a reasonable default for most assistants; raise it for genuinely multi-hop tasks, lower it for simple lookups.
Streaming the whole thing to the UI
For a chat interface you want tokens and tool activity to stream, not arrive in one blob. Swap generateText for streamText - same tools, same stopWhen - and pipe it to the client. The user sees the assistant think, call a tool, and answer in real time.
import { streamText, stepCountIs } from 'ai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: 'anthropic/claude-sonnet-4.6',
stopWhen: stepCountIs(5),
tools: { getOrders, checkInventory },
messages,
})
return result.toUIMessageStreamResponse()
}
Enter fullscreen mode Exit fullscreen mode
Gating the dangerous tools
Read-only tools (look up orders, search docs) can run automatically. Tools with side effects - refund a payment, delete a record, send an email - should not fire without a human in the loop. The cleanest pattern is to make the execute function return a proposal instead of performing the action, then confirm on the client before a second call actually commits it.
- Validate every argument at the boundary - the model's output is untrusted input, so treat
customerIdlike anything else a user could send. - For destructive actions, have
executereturn what it would do and surface a confirm step in the UI before committing. - Scope your tools to the current session - never let the model pass in a
customerIdthat is not the authenticated user.
From tools to a reusable agent
Once you have a set of tools you reuse across routes, wrap them in an agent instead of re-declaring the config each time. The AI SDK's ToolLoopAgent pattern bundles the model, tools, and stopWhen into one object you can call from anywhere and type end-to-end. Same loop, less repetition.
The whole point of tool calling: your assistant stops being a search box with personality and becomes a teammate that can actually operate your product - safely, because you define exactly what it can touch.
Keel, our AI SaaS starter kit, ships with a streaming assistant, tool-calling wired through the AI Gateway, and the auth and billing around it - so you start from a working agent instead of a blank route handler.



