You built a chatbot with the Vercel AI SDK. It streams tokens, it looks great, and then someone asks it about a framework release from last week and it makes something up. The model is stuck at its training cutoff, and no amount of prompt engineering fixes a knowledge gap.
The fix is a tool. The AI SDK has a clean, typed way to define one, and once you add a search-and-fetch tool the model can go read the live web before it answers. Let me show you the whole thing end to end.
How tools work in the AI SDK
A tool in the Vercel AI SDK is three things: a description the model reads to decide when to call it, a schema for the arguments, and an execute function that runs your code. You pass a map of tools to streamText or generateText, and the SDK handles the calling loop for you.
The key setting is the multi-step behavior. By default the SDK stops after the model emits a tool call. If you want it to call a tool, read the result, and then keep going until it produces a final answer, you raise the step limit. That one option is the difference between "the model asked to search" and "the model searched, read three pages, and answered."
Here is the shape of a single tool:
import { tool } from "ai";
import { z } from "zod";
const webSearch = tool({
description: "Search the web for current information. Returns results with full page content, not just snippets.",
parameters: z.object({
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
// your API call goes here
},
});
The parameters schema is Zod, so you get validation and TypeScript types for free. The .describe() calls matter more than they look. The model reads them, so a vague description gets you vague tool use.
Why search alone is not enough
Most people wire up a search API, dump the snippets into context, and call it done. Then they wonder why answers are still shallow.
Snippets are the problem. A traditional search result gives you a title, a URL, and a one- or two-line blurb written for a human scanning a results page. For "what is the capital of France" that blurb is plenty. For "what changed in the latest Next.js release" the answer is three paragraphs down on the actual page, and the snippet never contains it. The model hedges or hallucinates.
So you want two tools, not one. Search to find the right URLs, then fetch to pull the full page content the answer actually lives in. This is the same search-then-read pattern I covered in real-time web search for LLMs, and it is the piece most tutorials skip.
Wiring in link.sc
link.sc gives you exactly those two endpoints and returns clean markdown instead of raw HTML, which keeps your token count down. /v1/search returns results with page content, and /v1/fetch turns a single URL into markdown, JSON, or a screenshot. It handles JavaScript rendering, proxies, and retries on its side, so a page behind Cloudflare or built with client-side rendering still comes back as text.
Here are both tools, ready to drop into a route:
import { tool } from "ai";
import { z } from "zod";
const headers = {
"x-api-key": process.env.LINKSC_API_KEY!,
"Content-Type": "application/json",
};
export const webSearch = tool({
description: "Search the web for current information. Returns results with full page content.",
parameters: z.object({
query: z.string().describe("The search query"),
}),
execute: async ({ query }) => {
const res = await fetch("https://api.link.sc/v1/search", {
method: "POST",
headers,
body: JSON.stringify({ query }),
});
return res.json();
},
});
export const fetchUrl = tool({
description: "Fetch a single URL and return its content as clean markdown.",
parameters: z.object({
url: z.string().describe("The URL to fetch"),
}),
execute: async ({ url }) => {
const res = await fetch("https://api.link.sc/v1/fetch", {
method: "POST",
headers,
body: JSON.stringify({ url, format: "markdown" }),
});
return res.json();
},
});
The streamText route
Now the part that turns this into a working chatbot. In a Next.js App Router project, this is your app/api/chat/route.ts:
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import { webSearch, fetchUrl } from "@/lib/tools";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
system:
"You have web_search and fetch_url tools. Search to find sources, " +
"fetch the most relevant pages, and answer only from what you read. " +
"Cite the source URL for every claim. If the tools do not return the " +
"answer, say so instead of guessing.",
messages,
tools: { webSearch, fetchUrl },
maxSteps: 5,
});
return result.toDataStreamResponse();
}
That is the whole backend. On the client, useChat from @ai-sdk/react streams the response with no extra work. Swap openai("gpt-4o") for anthropic("claude-opus-4-8") and nothing else changes, because the tool definitions are provider agnostic.
The maxSteps: 5 line is doing the heavy lifting. The model searches, reads the tool result, decides it needs the full text of one result, calls fetchUrl, reads that, and only then writes its answer. Without a step budget above one, the loop stops after the first tool call and you get half an answer.
The system prompt does the grounding
Tool access does not stop hallucinations on its own. The instruction does.
The system prompt above tells the model to answer only from what the tools returned and to cite a URL for every claim. This flips its job from recall to reading comprehension over text you handed it, which is the thing LLMs are genuinely reliable at. Citations are a bonus safety net, since a wrong answer now comes with a link you can click to check. This grounding step matters as much as the tools themselves, a point I dig into more in giving an AI agent internet access.
A few things that will bite you
maxSteps at 1 is the most common mistake. The model calls a tool, the loop ends, and the user sees nothing useful. Set it to 4 or 5.
Watch your token count. Returning markdown instead of HTML cuts input tokens by a large margin, and fetching only the top two or three results rather than every hit keeps both your LLM bill and your latency in check.
Handle tool errors inside execute. If a fetch fails, return a small object like { error: "could not load page" } rather than throwing, so the model can recover and try another source instead of crashing the stream.
Cache pages that do not change often. A docs page you fetch on every request is a page you are paying for on every request.
On the cost question: a search or a fetch is roughly one credit on link.sc, and there are 500 free credits a month, which is enough to build and test a real agent before you pay anything.
Wire live search into your AI SDK app today: grab a free key at link.sc/register and start with 500 credits a month.