Most AI agent tutorials show you a demo. This one shows you a production system.
By the end of this post you will have a working Research Assistant agent that takes a question, searches the web, reads relevant pages, synthesizes findings, and saves a structured report. It will have memory, error handling, structured output, streaming responses, and a deployment path. The kind of system you could charge money for.
We are using the Vercel AI SDK v6, TypeScript, Claude Opus 4.6, and Next.js.
What We're Building
The Research Assistant:
- Takes a research question as input
- Searches the web for relevant sources using Brave Search
- Reads the content of promising pages using Jina Reader
- Synthesizes findings into a structured report
- Saves the completed report to a database
- Returns a streaming response so the user sees progress in real time
Here is the project structure:
research-agent/
app/
api/
research/
route.ts <- streaming API endpoint
lib/
agent/
tools.ts <- tool definitions
agent.ts <- agent definition
memory.ts <- session state management
db/
schema.ts <- database schema
tests/
tools.test.ts
Step 1: Install Dependencies
npm install ai@^6.0.0 @ai-sdk/react@^3.0.0
npm install zod nanoid drizzle-orm @neondatabase/serverless
Then run vercel link, enable AI Gateway in your project settings, and vercel env pull .env.local. This provisions a VERCEL_OIDC_TOKEN automatically. No provider-specific API keys in your code.
Step 2: Define the Tools
Tools are the first thing to build, because they define what your agent can actually do. Get tool design wrong and no amount of prompt engineering fixes it.
Each tool needs: a clear name, a description the model reads, typed input validation, structured error handling, and a predictable output format.
// lib/agent/tools.ts
import { tool } from "ai";
import { z } from "zod";
export const webSearchTool = tool({
description:
"Search the web for information on a topic. Returns a list of relevant URLs " +
"with titles and snippets. Use this to find sources before reading them.",
inputSchema: z.object({
query: z.string().describe("The search query. Be specific and targeted."),
num_results: z.number().min(1).max(10).default(5),
}),
execute: async ({ query, num_results }) => {
const SEARCH_API_KEY = process.env.BRAVE_SEARCH_API_KEY;
if (!SEARCH_API_KEY) throw new Error("BRAVE_SEARCH_API_KEY not set");
const url = new URL("https://api.search.brave.com/res/v1/web/search");
url.searchParams.set("q", query);
url.searchParams.set("count", String(num_results));
const response = await fetch(url.toString(), {
headers: { "X-Subscription-Token": SEARCH_API_KEY },
});
if (!response.ok) {
throw new Error(`Search API error: ${response.status}`);
}
const data = await response.json();
const results = (data.web?.results ?? []).map((r: Record<string, string>) => ({
url: r.url,
title: r.title,
snippet: r.description,
}));
return { results, query };
},
});
export const readPageTool = tool({
description:
"Fetch and read the content of a web page. Returns the main text content. " +
"Use this after web_search to read promising sources in detail.",
inputSchema: z.object({
url: z.string().url().describe("The URL of the page to read."),
}),
execute: async ({ url }) => {
const JINA_API_KEY = process.env.JINA_API_KEY;
const readerUrl = `https://r.jina.ai/${encodeURIComponent(url)}`;
const response = await fetch(readerUrl, {
headers: {
...(JINA_API_KEY ? { Authorization: `Bearer ${JINA_API_KEY}` } : {}),
"X-Return-Format": "text",
},
});
if (!response.ok) {
// Return a structured error the agent can reason about
return { url, error: `Failed to read page: ${response.status}`, content: null };
}
const content = await response.text();
return { url, content: content.slice(0, 8000), truncated: content.length > 8000 };
},
});
export const saveReportTool = tool({
description:
"Save the completed research report to the database. Call this ONCE at the end " +
"when you have finished your research and written the full report.",
inputSchema: z.object({
title: z.string(),
summary: z.string().describe("A 2-3 sentence executive summary."),
sections: z.array(z.object({ heading: z.string(), content: z.string() })),
sources: z.array(z.object({ url: z.string(), title: z.string() })),
}),
execute: async ({ title, summary, sections, sources }) => {
const { db } = await import("@/lib/db");
const { reports } = await import("@/lib/db/schema");
const { nanoid } = await import("nanoid");
const id = nanoid();
await db.insert(reports).values({
id, title, summary,
sections: JSON.stringify(sections),
sources: JSON.stringify(sources),
createdAt: new Date(),
});
return { success: true, reportId: id };
},
});
Notice two things. First, the description field matters more than anything else -- the model reads it to decide when and how to call the tool. Be specific about preconditions ("use this after web_search") and postconditions ("call this ONCE at the end"). Second, readPageTool returns a structured error object instead of throwing when a page fails. The agent can reason about a structured error and try a different URL. An uncaught exception gives the agent nothing to work with.
Step 3: Build the Agent
With tools defined, the agent uses ToolLoopAgent from AI SDK v6:
// lib/agent/agent.ts
import { ToolLoopAgent, stepCountIs } from "ai";
import { webSearchTool, readPageTool, saveReportTool } from "./tools";
export const researchAgent = new ToolLoopAgent({
model: "anthropic/claude-opus-4.6",
instructions: `You are a research assistant that produces high-quality reports.
Your research process:
1. Search for sources using web_search. Start with 2-3 targeted queries.
2. Read the most relevant sources with read_page. Read at least 3 sources.
3. Synthesize findings into a structured report.
4. Save the completed report using save_report.
Standards:
- Be specific and cite your sources.
- Do not fabricate information. If you cannot find something, say so.
- Call save_report exactly once when your research is complete.`,
tools: {
web_search: webSearchTool,
read_page: readPageTool,
save_report: saveReportTool,
},
stopWhen: stepCountIs(20),
});
The stopWhen: stepCountIs(20) guard is non-negotiable. Without a stop condition, a confused agent can loop indefinitely burning tokens and API budget. Twenty steps is generous -- most research tasks finish in 8-12. Set it based on observed usage.
Step 4: Add Session Memory
The basic agent starts fresh every time. A production system should remember what it has researched within a session:
// lib/agent/memory.ts
export async function loadSessionContext(sessionId: string) {
const session = await db.query.sessions.findFirst({
where: eq(sessions.id, sessionId),
});
if (!session) return null;
return {
id: session.id,
previousQuestions: JSON.parse(session.previousQuestions ?? "[]"),
previousFindings: session.previousFindings ?? "",
};
}
export function buildInstructionsWithContext(
baseInstructions: string,
context: { previousQuestions: string[]; previousFindings: string } | null
): string {
if (!context || context.previousQuestions.length === 0) return baseInstructions;
return `${baseInstructions}
Previous research in this session:
- Questions researched: ${context.previousQuestions.join(", ")}
- Key findings so far: ${context.previousFindings}
Use this context to avoid redundant searches.`;
}
Bounding memory is not optional. Cap question history at 10 entries and truncate findings at 2000 characters. Unbounded context accumulation is a common failure mode -- the context window fills up and the model starts ignoring earlier information.
Step 5: Deploy as a Streaming API Route
Users should not wait in silence while an agent works for 30 seconds. Stream the response so they see output as it generates:
// app/api/research/route.ts
import { createAgentUIStreamResponse, ToolLoopAgent } from "ai";
import { nanoid } from "nanoid";
import { researchAgent } from "@/lib/agent/agent";
import { loadSessionContext, buildInstructionsWithContext } from "@/lib/agent/memory";
export const maxDuration = 300;
export async function POST(req: Request) {
const body = await req.json();
const { messages, question, sessionId } = body;
if (!question || typeof question !== "string") {
return new Response("Missing question", { status: 400 });
}
const context = sessionId ? await loadSessionContext(sessionId) : null;
const agent = new ToolLoopAgent({
...researchAgent,
instructions: buildInstructionsWithContext(
researchAgent.instructions as string,
context
),
});
return createAgentUIStreamResponse({
agent,
uiMessages: messages?.length > 0 ? messages : [
{ id: nanoid(), role: "user", parts: [{ type: "text", text: `Research: ${question}` }] },
],
});
}
On the client side:
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
export default function ResearchPage() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: "/api/research" }),
});
return (
<div>
{messages.map((m) => (
<div key={m.id}>{/* render message parts */}</div>
))}
<button
onClick={() => sendMessage({ text: "What is the current state of quantum computing?" })}
disabled={status === "streaming"}
>
Research
</button>
</div>
);
}
Two quick notes on deployment: set BRAVE_SEARCH_API_KEY and JINA_API_KEY in Vercel project settings. The VERCEL_OIDC_TOKEN for AI Gateway auth is provisioned automatically via vercel env pull. Never hardcode secrets in source files.
Step 6: Write Tests
An agent is software. Test it like software.
// tests/tools.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { webSearchTool, readPageTool } from "@/lib/agent/tools";
const mockExecOptions = {
toolCallId: "test-id",
messages: [],
abortSignal: new AbortController().signal,
};
describe("webSearchTool", () => {
beforeEach(() => { process.env.BRAVE_SEARCH_API_KEY = "test-key"; });
it("returns structured results on success", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
web: { results: [{ url: "https://example.com", title: "Example", description: "An example" }] },
}),
});
const result = await webSearchTool.execute!(
{ query: "test query", num_results: 1 },
mockExecOptions
);
expect(result.results).toHaveLength(1);
});
});
describe("readPageTool", () => {
it("returns structured error when fetch fails", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
const result = await readPageTool.execute!(
{ url: "https://example.com/missing" },
mockExecOptions
);
// Should NOT throw -- return structured error the agent can reason about
expect(result.content).toBeNull();
expect(result.error).toContain("404");
});
});
The Production Checklist
Before calling this production-ready:
- Input validation with Zod on every API boundary
- Structured error returns from all tools -- no raw exceptions leaking to the agent
-
maxDurationset to 300 for Vercel Pro - Memory bounded: question history capped, findings truncated
- Retry logic only for transient errors (429), not all errors
- Unit tests for each tool's happy path and error case
- Secrets in Vercel environment variables, never in source
This implementation is roughly 400 lines of TypeScript across seven files. Small enough to understand completely, large enough to handle real production traffic.
The key insight: a production agent is mostly tool design and reliability engineering. The model handles the reasoning. You handle everything around it.
This post is adapted from Production AI Agents: Build, Deploy, and Monetize Autonomous Systems, available on Amazon Kindle. The book goes deeper with 12 chapters of real code, battle-tested patterns, and a complete hands-on tutorial.
I build production AI systems. More at astraedus.dev.
Top comments (0)