Give a Vercel AI SDK Agent Live Web Search
Your AI SDK agent knows everything up to its training cutoff and nothing after that. The fix is one tool. Wire the Serpex TypeScript SDK into a tool() definition, hand it to generateText or streamText, and the model can pull live web search results into its own reasoning whenever it decides it needs them.
This walks through the whole thing: install, define the tool, wire it up with multi-step calling so the agent can act on what it finds, and a variant that hands back full page content instead of just a snippet.
What do I need first?
A Serpex API key. Sign up at serpex.dev and you get 200 free credits with no card required. Grab the key from your dashboard and put it in an environment variable, never in the code itself.
# .env.localSERPEX_API_KEY=sk_your_api_key
You'll also need the AI SDK already set up with a model provider (we'll use @ai-sdk/openai in the examples below, but any provider works the same way).
How do I install the SDK?
npm install serpex
That's the whole install. No config step, no separate types package. SerpexClient and all its types ship from the same import.
How do I define the search tool?
This is the part that matters. A tool for the AI SDK is a zod schema (what the model is allowed to pass in) plus an execute function (what actually runs). We use inputSchema, not parameters, since that's the current AI SDK naming.
// lib/tools/web-search.tsimport { z } from "zod";import { tool } from "ai";import { SerpexClient } from "serpex";const serpex = new SerpexClient(process.env.SERPEX_API_KEY!);export const webSearch = tool({description:"Search the live web for current information. Use this for anything recent, time sensitive, or outside your training data: news, prices, current events, docs for a library, current versions.",inputSchema: z.object({query: z.string().describe("The search query"),}),execute: async ({ query }) => {const { results } = await serpex.search({ q: query });return results.map((r) => ({title: r.title,url: r.url,snippet: r.snippet,}));},});
We keep the returned object small on purpose. The model gets titles, URLs, and snippets, which is usually all it needs to answer or to decide which page to read next. client.search() also returns metadata.credits_used if you want to log spend per call, but there's no need to pass that back to the model.
One thing that trips people up the first time: SerpexClient throws a SerpApiException on a non-2xx response (bad key, no credits, rate limit). If you don't want a bad search to kill the whole agent turn, catch it inside execute and return something the model can react to instead of letting it bubble up.
execute: async ({ query }) => {try {const { results } = await serpex.search({ q: query });return results.map((r) => ({ title: r.title, url: r.url, snippet: r.snippet }));} catch (error) {return { error: "Search failed, try a different query." };}},
How do I wire it into generateText?
Pass the tool in the tools object, keyed by whatever name you want the model to see:
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import { webSearch } from "./lib/tools/web-search";const { text } = await generateText({model: openai("gpt-4o"),tools: { webSearch },prompt: "What's the current weather like in Lisbon, and what should I wear?",});console.log(text);
Run this as is and the model can call webSearch once. Most real questions need more than that: search, notice the first result doesn't fully answer it, search again with a narrower query, then write the answer. For that you need multi-step tool calls, which brings us to the part that's easy to get wrong.
How do I let the agent search more than once?
In the current AI SDK, a single generateText call only goes back to the model with tool results, and lets it keep calling tools, when you set stopWhen. Without it, the agent stops after the first round of tool calls whether or not it actually has an answer.
import { generateText, stepCountIs } from "ai";import { openai } from "@ai-sdk/openai";import { webSearch } from "./lib/tools/web-search";const { text, steps } = await generateText({model: openai("gpt-4o"),tools: { webSearch },stopWhen: stepCountIs(5),prompt: "What's the latest stable version of Next.js, and what changed in it?",});console.log(text);console.log(`Took ${steps.length} step(s)`);
stepCountIs(5) caps the run at five model steps, so a confused model can't run up your bill searching forever. It's also the easiest setting to miss. Leave it out and the agent stops after one search, mid-thought, with no error and no warning. It just quietly returns whatever it had. If your agent seems to ignore search results or cut answers short, check stopWhen before anything else.
streamText takes the exact same stopWhen option and streams the whole multi-step run, tool calls included:
import { streamText, stepCountIs } from "ai";import { openai } from "@ai-sdk/openai";import { webSearch } from "./lib/tools/web-search";const result = streamText({model: openai("gpt-4o"),tools: { webSearch },stopWhen: stepCountIs(5),prompt: "What's the latest stable version of Next.js, and what changed in it?",});for await (const chunk of result.textStream) {process.stdout.write(chunk);}
How do I get full page content instead of just snippets?
Sometimes a snippet isn't enough: the model needs to actually read the page to answer well. Set include_content: true on the search call and Serpex fetches each top result's page and returns it as markdown, in the same request, no separate crawl call needed.
export const webSearchWithContent = tool({description:"Search the live web and return the full page content (as markdown) for each top result, not just a snippet. Use this when a summary won't be enough and the model needs to actually read the page.",inputSchema: z.object({query: z.string().describe("The search query"),}),execute: async ({ query }) => {const { results, metadata } = await serpex.search({q: query,include_content: true,content_results: 5,});return {delivered: `${metadata.content_delivered}/${metadata.content_requested}`,pages: results.map((r) => ({title: r.title,url: r.url,content: r.content ?? null,content_error: r.content_error ?? null,})),};},});
Page content is best-effort: some sites block fetching or disallow it in robots.txt, and those results come back with content_error instead of content. Serpex documents this at roughly 79% of requested results actually delivering content, so always check for content_error and let the model fall back to the snippet in your prompt or tool description.
It costs more than a plain search, and the billing is based on what actually came back, not what you asked for: 3 credits when 1 to 5 pages are delivered, 6 credits when 6 to 10 are delivered, and just the plain search rate of 1 credit if none deliver. A normal search without include_content is 1 credit. Repeat the same query within about 5 minutes and it's free either way.
FAQ
Does this work with streamText the same way as generateText?
Yes. Both take the same tools and stopWhen options, and both return a steps array once the run finishes.
Why isn't my agent calling the tool a second time?
Almost always a missing stopWhen. Without it, generateText and streamText stop after the first batch of tool calls even if the model still has more to do.
Can I use this in a Next.js API route or server action?
Yes, that's the normal setup. Keep SERPEX_API_KEY server side only. Never ship it to client-side JavaScript.
What happens if the search fails?
SerpexClient throws a SerpApiException with a statusCode and details. Catch it inside execute so a failed search becomes a message the model can react to, not a crash.
Sign up for 200 free credits, no card required, and try the tool against your own agent today.