Vercel · TypeScript
Build a research agent with Vercel AI SDK
Build a research agent with the Vercel AI SDK, defining unlob tools with Zod schemas and streaming the result to a React UI.
What you are building
Your app
generateText / streamText
The loop, bounded by stopWhen
React UI
useChat, streamed
Tools
webSearch
Zod schema
assembleContext
Zod schema
Services
Anthropic API
claude-opus-5
unlob
Your own index
Install
npm i ai @ai-sdk/anthropic zodAlready have Vercel AI SDK wired up? TheVercel AI SDK integration pagehas the connection config on its own — this page assumes you have it and gets on with building.
Step by step
Define tools with Zod
The Zod schema is the tool schema. Describe every parameter — those descriptions are what the model reads when deciding what to pass.
tools.tstypescript import { tool } from "ai"; import { z } from "zod"; const API = "https://api.unlob.com"; const headers = { "x-api-key": process.env.UNLOB_API_KEY! }; export const webSearch = tool({ description: "Search the web. Returns metadata only — url, title, snippet and " + "independent_sources. Never returns page bodies.", inputSchema: z.object({ query: z.string().describe("What to search for"), minSources: z .number() .default(2) .describe("Require this many independent sources; use 3 for facts"), }), execute: async ({ query, minSources }) => { const params = new URLSearchParams({ q: query, min_independent_sources: String(minSources), limit: "8", }); const res = await fetch(`${API}/search?${params}`, { headers }); if (!res.ok) return { error: `search failed: ${res.status}` }; const { results } = await res.json(); return results; }, });Let the model loop
stopWhen with stepCountIs is what turns a single tool call into an agent. Without it the model calls one tool and stops, which is the single most common reason a first attempt "does not work".
agent.tstypescript import { anthropic } from "@ai-sdk/anthropic"; import { generateText, stepCountIs } from "ai"; import { webSearch, assembleContext } from "./tools"; const { text, steps } = await generateText({ model: anthropic("claude-opus-5"), tools: { webSearch, assembleContext }, stopWhen: stepCountIs(8), // without this it stops after one tool call system: "Search before you answer. Prefer claims carried by several independent " + "sources, and say so when a claim rests on one. Always cite URLs.", prompt: "What changed in the EU AI Act GPAI rules in 2026?", });Stream it into a page
This is the reason to pick this SDK. Swap generateText for streamText in a route handler and useChat on the client, and the tool calls and answer arrive progressively — including which sources the agent consulted, which is what turns an answer into something a reader can check.
app/api/chat/route.tstypescript import { anthropic } from "@ai-sdk/anthropic"; import { streamText, stepCountIs, convertToModelMessages } from "ai"; import { webSearch, assembleContext } from "@/lib/tools"; export const maxDuration = 60; // searching takes longer than a plain chat turn export async function POST(req: Request) { const { messages } = await req.json(); const result = streamText({ model: anthropic("claude-opus-5"), messages: convertToModelMessages(messages), tools: { webSearch, assembleContext }, stopWhen: stepCountIs(8), system: "Search before you answer. Prefer claims carried by several " + "independent sources, and say so when a claim rests on one. Cite URLs.", }); // sendSources surfaces tool results to the client, so the UI can render // the URLs the agent actually consulted rather than just its prose. return result.toUIMessageStreamResponse({ sendSources: true }); }
What happens when it runs
The whole thing
Complete and runnable. Set the two environment variables and it works.
/**
* A research agent on the Vercel AI SDK, backed by the unlob web index.
*
* npm i ai @ai-sdk/anthropic zod
* export UNLOB_API_KEY=ulb_...
* export ANTHROPIC_API_KEY=sk-ant-...
*/
import { anthropic } from "@ai-sdk/anthropic";
import { generateText, tool, stepCountIs } from "ai";
import { z } from "zod";
const API = "https://api.unlob.com";
const headers = { "x-api-key": process.env.UNLOB_API_KEY! };
const webSearch = tool({
description:
"Search the web. Returns metadata only — url, title, snippet and " +
"independent_sources. Never returns page bodies.",
inputSchema: z.object({
query: z.string().describe("What to search for"),
minSources: z
.number()
.default(2)
.describe("Require this many independent sources; use 3 for facts"),
}),
execute: async ({ query, minSources }) => {
const params = new URLSearchParams({
q: query,
min_independent_sources: String(minSources),
limit: "8",
});
const res = await fetch(`${API}/search?${params}`, { headers });
// Return the error to the model rather than throwing — it can retry or
// explain, where a thrown error just ends the run.
if (!res.ok) return { error: `search failed: ${res.status}` };
const { results } = await res.json();
return results;
},
});
const assembleContext = tool({
description:
"Build a corroborated, deduplicated context pack for a question, packed " +
"to a token budget. Prefer this over several searches when you need to " +
"read source material rather than just find it.",
inputSchema: z.object({
query: z.string().describe("The question the pack should answer"),
budget: z.number().default(3000).describe("Maximum tokens to return"),
}),
execute: async ({ query, budget }) => {
const params = new URLSearchParams({
query,
budget: String(budget),
min_independent_sources: "2",
});
const res = await fetch(`${API}/assemble_context?${params}`, { headers });
if (!res.ok) return { error: `assemble failed: ${res.status}` };
return res.json();
},
});
const { text, steps } = await generateText({
model: anthropic("claude-opus-5"),
tools: { webSearch, assembleContext },
stopWhen: stepCountIs(8),
system:
"Search before you answer. Prefer claims carried by several independent " +
"sources, and say so when a claim rests on one. Always cite URLs.",
prompt: "What changed in the EU AI Act GPAI rules in 2026?",
});
console.log(text);
console.log(`(${steps.length} steps)`);What will go wrong
The failures that cost an afternoon rather than a minute, because they produce something that looks like it is working.
Without `stopWhen`, the model calls one tool and stops. This is the number one reason a first Vercel AI SDK agent appears broken — it is not looping, and nothing tells you so.
Return errors from `execute` instead of throwing. A thrown error ends the whole run; a returned `{ error }` lets the model retry or tell the user what went wrong.
Zod `.describe()` on every field is not optional in practice. It is the parameter documentation the model reads, and omitting it is how you get a whole sentence passed as `minSources`.
Keep API keys server-side. Tools defined in a route handler are fine; the same tool imported into a client component ships your key to the browser.
Where this agent can go next
Every result the agent receives is a node in the coverage graph. These are the tools you can add to it without leaving Vercel AI SDK — each is one more entry in the same tools list.
Frequently asked questions
generateText or streamText?
streamText when a human is waiting and should see progress; generateText when the result feeds another system. The tool definitions are identical, so switching later is a one-line change.
How do I show which sources the agent used?
Read `steps` — every tool call and result is there, including the URLs and independent_sources counts from unlob. Rendering that list is what turns an answer into something a user can check.
You need a key to run this
10,000 requests a month on the free tier, no card. Enough to build the agent and put a real evaluation set through it.