Web Search as a Tool in the Vercel AI SDK

The AI SDK gives you a typed tool in about fifteen lines — and then two things bite: the model answers with empty text because the loop stopped at the tool call, and the key ends up in a client bundle. Both have one-line fixes.
TL;DR
- •AI SDK 5 tools declare inputSchema (a Zod schema); v4 called it parameters.
- •Without stopWhen the request ends at finishReason: 'tool-calls' with empty text — stopWhen: stepCountIs(5) is what lets the model answer in words.
- •Keep the tool's execute on the server: a Route Handler or server action, never a client component.
- •POST https://www.apipick.com/api/search/web with an x-api-key header: 15 credits per call, charged only on HTTP 200.
- •Return three fields per result. Every extra field is re-sent to the model on every subsequent step of the loop.
What does a typed search tool look like?
The AI SDK's tool() helper takes a description, a Zod schema, and an execute function. The schema is fed to the model and validates its arguments before your code runs, so malformed tool calls never reach the fetch.
// lib/tools.ts — server-only
import { tool } from "ai";
import { z } from "zod";
export const webSearch = tool({
description:
"Search the live web. Use for news, prices, and anything published " +
"after your training cutoff. Write the query in plain words.",
inputSchema: z.object({
query: z.string().describe("Plain-language search query"),
countryCode: z
.string()
.length(2)
.optional()
.describe("ISO country code to localise results, e.g. US or GB"),
}),
execute: async ({ query, countryCode }) => {
const res = await fetch("https://www.apipick.com/api/search/web", {
method: "POST",
headers: {
"x-api-key": process.env.APIPICK_KEY!,
"content-type": "application/json",
},
body: JSON.stringify({
query,
max_num_results: 5,
...(countryCode ? { country_code: countryCode } : {}),
}),
});
if (!res.ok) return { error: `search failed: ${res.status}` };
const data = await res.json();
return data.results.map((r: any) => ({
title: r.title,
url: r.url,
snippet: r.snippet,
}));
},
});Why does the model answer with nothing?
This is the single most common AI SDK tool bug, and it is not a bug. generateText makes one model request by default. The model calls your tool, the request finishes with finishReason: 'tool-calls', and text is empty — because the model never got a second turn in which to write prose.
import { generateText, stepCountIs } from "ai";
import { webSearch } from "@/lib/tools";
const { text, steps } = await generateText({
model: "anthropic/claude-sonnet-5",
tools: { webSearch },
stopWhen: stepCountIs(5), // <- the fix
system:
"Ground factual claims in search results and cite the URL. " +
"If search returns nothing useful, say so rather than guessing.",
prompt: "What changed in the EU AI Act timeline this year?",
});stopWhen turns one request into a tool-calling loop and caps how far it can run. In AI SDK 4 the equivalent knob was maxSteps.
How do you stream it from a Next.js route?
Same tool, streamText instead, inside a Route Handler so the key never leaves the server.
// app/api/chat/route.ts
import { streamText, stepCountIs, convertToModelMessages } from "ai";
import { webSearch } from "@/lib/tools";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: "anthropic/claude-sonnet-5",
messages: convertToModelMessages(messages),
tools: { webSearch },
stopWhen: stepCountIs(5),
system: "Cite the URL for every factual claim.",
});
return result.toUIMessageStreamResponse();
}The client uses useChat and receives tool calls and results as parts of the message stream, so you can render a "searching…" state without any extra plumbing.
Why keep the result to three fields?
Because in a multi-step loop, a tool result is re-sent on every subsequent step. It is part of the message history the model reads each time round. A result object with scores, source types, timestamps and metadata is not paid for once — it is paid for on step 2, step 3, and step 4 as well.
Cap the count at the source with max_num_results (1–5) rather than fetching wide and slicing in TypeScript. Slicing saves tokens; not fetching saves tokens and time.
How do you add page reading?
Give the model a second tool for the follow-up read, and constrain the URL count in the schema so it cannot ask for twelve pages.
export const readPages = tool({
description:
"Fetch clean readable text for specific URLs. Call after webSearch " +
"when a snippet is not enough to answer.",
inputSchema: z.object({
urls: z.array(z.string().url()).min(1).max(3),
}),
execute: async ({ urls }) => {
const res = await fetch("https://www.apipick.com/api/extract", {
method: "POST",
headers: {
"x-api-key": process.env.APIPICK_KEY!,
"content-type": "application/json",
},
body: JSON.stringify({ urls }),
});
if (!res.ok) return { error: `extract failed: ${res.status}` };
const data = await res.json();
return data.results
.filter((r: any) => r.status === "ok")
.map((r: any) => ({ url: r.url, content: r.content.slice(0, 6000) }));
},
});The .max(3) in the schema is enforced before execute runs, which is a stronger guarantee than asking the model nicely in the description.
What should you check before shipping?
stopWhenis set. Without it, tools work and answers are empty.- The key is read from
process.envon the server. A tool defined in a client component is a leaked credential waiting to happen. - Errors are returned, not thrown. Returning
{ error }lets the model recover and rephrase; throwing kills the run. - Only-on-success billing. Schema validation failures and retries are common in a tool loop; with HTTP-200 billing they cost nothing.
The same tool in other frameworks: LangChain, CrewAI, n8n, and raw OpenAI / Claude. Start with a free key: 100 credits, no card.
Frequently Asked Questions
Is it inputSchema or parameters?
inputSchema in AI SDK 5, parameters in v4. If you are copying a snippet from an older post and the model never calls your tool, this is usually why — the key is ignored rather than rejected, so the tool registers with no arguments and the model cannot work out how to invoke it.
Why does my model return empty text after calling the tool?
Because the request stopped at the tool call. By default generateText and streamText make a single model request; once a tool call is produced the run finishes with finishReason: 'tool-calls' and no prose. Adding stopWhen: stepCountIs(5) turns that single request into a loop, so the model gets a chance to read the tool result and write an answer.
Where should the tool's execute run?
On the server, always. Put the tool in a Route Handler (app/api/chat/route.ts) or a server action so the API key stays in process.env on the server. A tool defined in a client component either cannot see the key or, worse, ships it to the browser in the bundle.
How much should the tool return?
Three fields per result: title, url, snippet. In a multi-step loop every tool result is part of the message history that gets re-sent on each subsequent step, so an over-wide result is not a one-time token cost — it is charged again on every step after it. Cap the count with max_num_results (1–5, default 5) rather than slicing in TypeScript.
What does each call cost?
15 credits per search, at $1 per 1,000 credits — about $0.015. Credits are deducted only on HTTP 200, so a malformed request, a timeout, or a retry after a schema validation failure costs nothing.
APIs used in this article
Sarah Choy is the CEO of API Pick. She writes about building production-ready APIs for AI agents and LLM workflows.