Skip to content
unlob

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

Your side of the API. unlob is one HTTPS service among the others — nothing here is specific to how our index works, so this agent survives a change of framework.

Install

Dependenciesbash
npm i ai @ai-sdk/anthropic zod

Already 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

  1. 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;
      },
    });
  2. 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?",
    });
  3. 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

BrowserRoute handlerClaudeunlobPOST /api/chatmessages + toolstool-call: webSearchGET /searchhitstool-result (step 2)text streamstreamed to UI
One turn of the loop. Search returns metadata only, so the agent spends a few hundred tokens deciding rather than tens of thousands reading.

The whole thing

Complete and runnable. Set the two environment variables and it works.

research-agent.ts — completetypescript
/**
 * 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.

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.

relatedNear-duplicatescorroborateIndependent sourcesauthoritiesAuthoritative hostsdossierThe entity briefpathA second documentassemble_contextA packed context setA search resultwhat your agent has
The two marked in blue are the tools this tutorial wires up. The other four are the same shape.

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.