AI Agents

Build a Research Agent with Claude: Step-by-Step Tutorial (2026)

Build a production-ready research agent using Claude, web search, and structured output - with tool definitions, the agentic loop, result synthesis.

A research agent is the canonical first agent to build: it has a clear goal (answer a question with sourced evidence), requires tool use (web search, page fetch), and demonstrates the core agentic loop in a way that is immediately useful. It is also genuinely non-trivial to do well - the difference between a research agent that returns hallucinations with fake citations and one that returns accurate, source-backed findings is in the tool design, the system prompt, and the synthesis step. This tutorial builds the latter.

Research agent
A research agent is an AI agent that accepts a question, uses web search and page fetch tools to gather evidence from multiple sources across an agentic loop, and synthesises a structured answer with source citations - distinguishing it from a single-call LLM response by its ability to iteratively refine its search strategy based on what it finds.

What we're building

```text
User question
      │
      ▼
┌──────────────────────────────────┐
│  Research Agent (claude-sonnet-5) │
│                                   │
│  Tools:                           │
│   ├── web_search (Brave/SerpAPI)  │
│   └── fetch_page (HTTP + parse)   │
│                                   │
│  Loop until: answer confirmed     │
│  Max iterations: 15               │
└──────────┬───────────────────────┘
           │
           ▼
┌──────────────────────────────────┐
│  Synthesis: structured output    │
│   - summary (200-400 words)      │
│   - key_findings (3-5 bullets)   │
│   - sources (URL + title + date) │
│   - confidence (low/med/high)    │
└──────────────────────────────────┘
```

Prerequisites: Node.js 18+, an Anthropic API key, and a search API key (Brave Search or SerpAPI both work).

Step 1 - Define the tools

The agent needs two tools: a web search to find relevant URLs, and a page fetcher to read those pages. Define both before writing any loop logic:

import Anthropic from '@anthropic-ai/sdk';

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const tools: Anthropic.Tool[] = [
  {
    name: 'web_search',
    description: 'Search the web for information. Use this first to find relevant sources before fetching full pages. Returns a list of results with titles, URLs, and snippets.',
    input_schema: {
      type: 'object' as const,
      properties: {
        query: {
          type: 'string',
          description: 'The search query. Be specific. Use quotes for exact phrases.',
        },
        num_results: {
          type: 'number',
          description: 'Number of results to return (1-10). Default 5.',
          minimum: 1,
          maximum: 10,
        },
      },
      required: ['query'],
    },
  },
  {
    name: 'fetch_page',
    description: 'Fetch and read the full content of a web page. Use this after web_search to read the actual content of promising results. Returns cleaned text content.',
    input_schema: {
      type: 'object' as const,
      properties: {
        url: {
          type: 'string',
          description: 'The full URL to fetch. Must start with https://',
        },
        max_chars: {
          type: 'number',
          description: 'Maximum characters to return (default 8000). Increase for long documents.',
        },
      },
      required: ['url'],
    },
  },
];

Step 2 - Implement the tool executors

async function webSearch(query: string, numResults = 5): Promise {
  const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${numResults}`;
  const res = await fetch(url, {
    headers: { 'X-Subscription-Token': process.env.BRAVE_API_KEY! },
  });

  if (!res.ok) throw new Error(`Search API error: ${res.status}`);
  const data = await res.json();

  return (data.web?.results ?? []).map((r: any, i: number) =>
    `[${i + 1}] ${r.title}
    URL: ${r.url}
    Snippet: ${r.description}`
  ).join('

');
}

async function fetchPage(url: string, maxChars = 8000): Promise {
  if (!url.startsWith('https://')) throw new Error('URL must start with https://');

  const res = await fetch(url, {
    headers: { 'User-Agent': 'ResearchAgent/1.0 (research tool)' },
    signal: AbortSignal.timeout(10_000), // 10 second timeout
  });

  if (!res.ok) throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);

  const html = await res.text();

  // Strip HTML tags - a real implementation would use a proper parser
  const text = html
    .replace(/]*>[sS]*?/gi'')
    .replace(/]*>[sS]*?/gi'')
    .replace(/<[^>]+>/g' ')
    .replace(/s+/g' ')
    .trim();

  return text.slice(0, maxChars) + (text.length > maxChars ? '
[Content truncated]' : '');
}

async function executeTools(
  content: Anthropic.ContentBlock[]
): Promise {
  const toolCalls = content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');

  return Promise.all(toolCalls.map(async (call) => {
    try {
      let result: string;

      if (call.name === 'web_search') {
        const { query, num_results } = call.input as { query: string; num_results?: number };
        result = await webSearch(query, num_results);
      } else if (call.name === 'fetch_page') {
        const { url, max_chars } = call.input as { url: string; max_chars?: number };
        result = await fetchPage(url, max_chars);
      } else {
        result = `Unknown tool: ${call.name}`;
      }

      return { type: 'tool_result' as const, tool_use_id: call.id, content: result };
    } catch (err) {
      return {
        type: 'tool_result' as const,
        tool_use_id: call.id,
        content: `Error: ${(err as Error).message}. Try a different approach.`,
        is_error: true,
      };
    }
  }));
}

Step 3 - The agentic loop

const SYSTEM_PROMPT = `You are a research agent. Your goal is to answer questions accurately using web sources.

Research process:
1. Start with a web_search to find relevant sources
2. Use fetch_page to read the most promising results (typically 2-4 pages)
3. Search again if the initial results do not fully answer the question
4. Once you have sufficient sourced information, stop calling tools and provide your final answer

Rules:
- Only claim information that appears in a source you fetched
- If sources contradict each other, note the contradiction
- If you cannot find reliable information, say so - do not guess
- Always note the source URL when stating a fact`;

async function runResearchAgent(question: string): Promise {
  const messages: Anthropic.MessageParam[] = [
    { role: 'user', content: question }
  ];

  const MAX_ITERATIONS = 15;

  for (let i = 0; i < MAX_ITERATIONS; i++) {
    const response = await client.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 4096,
      system: SYSTEM_PROMPT,
      tools,
      messages,
    });

    messages.push({ role: 'assistant', content: response.content });

    if (response.stop_reason === 'end_turn') {
      // Agent decided it has enough information - synthesise
      const rawAnswer = response.content
        .filter((b): b is Anthropic.TextBlock => b.type === 'text')
        .map(b => b.text)
        .join('
');

      return await synthesise(question, rawAnswer, messages);
    }

    if (response.stop_reason === 'tool_use') {
      const toolResults = await executeTools(response.content);
      messages.push({ role: 'user', content: toolResults });
    }
  }

  throw new Error(`Research agent did not complete within ${MAX_ITERATIONS} iterations`);
}

Step 4 - Structured synthesis

The raw answer from the loop is well-reasoned prose. The synthesis step converts it into a structured format that calling code can use reliably - using forced tool selection to guarantee the output shape:

interface ResearchResult {
  summary: string;
  key_findings: string[];
  sources: Array<{ url: string; title: string; relevance: string }>;
  confidence: 'low' | 'medium' | 'high';
}

async function synthesise(
  question: string,
  rawAnswer: string,
  messageHistory: Anthropic.MessageParam[]
): Promise {
  const synthesisResponse = await client.messages.create({
    model: 'claude-sonnet-5',
    max_tokens: 2048,
    tools: [{
      name: 'submit_research_result',
      description: 'Submit the final structured research result.',
      input_schema: {
        type: 'object' as const,
        properties: {
          summary: { type: 'string', description: '200-400 word summary answering the question' },
          key_findings: {
            type: 'array',
            items: { type: 'string' },
            description: '3-5 specific, sourced bullet points',
            minItems: 1,
            maxItems: 5,
          },
          sources: {
            type: 'array',
            items: {
              type: 'object',
              properties: {
                url: { type: 'string' },
                title: { type: 'string' },
                relevance: { type: 'string', description: 'One sentence on what this source contributed' },
              },
              required: ['url', 'title', 'relevance'],
            },
          },
          confidence: {
            type: 'string',
            enum: ['low', 'medium', 'high'],
            description: 'low if sources were sparse or contradictory; high if multiple independent sources agree',
          },
        },
        required: ['summary', 'key_findings', 'sources', 'confidence'],
      },
    }],
    tool_choice: { type: 'tool', name: 'submit_research_result' },
    messages: [
      ...messageHistory,
      { role: 'user', content: `Synthesise your research into a structured result for the question: "${question}"` },
    ],
  });

  const toolCall = synthesisResponse.content.find(b => b.type === 'tool_use') as Anthropic.ToolUseBlock;
  return toolCall.input as ResearchResult;
}

Handling failure modes

  • Search API rate limits: Wrap webSearch with exponential backoff. Most search APIs allow 1 request/second on free tiers.
  • Paywalled pages: fetchPage returns the paywall landing page, not the content. The agent will see limited content and typically searches for an alternative source. You can detect paywalls by checking if the returned text contains "subscribe" or "sign in" and returning an explicit error message.
  • Agent loops without fetching pages: If the agent only calls web_search and never fetch_page, it will answer from snippets rather than full source content. Add a system prompt instruction: "You must fetch at least 2 pages before providing a final answer."

When to extend this pattern

This single-agent research pattern handles most research tasks up to moderate complexity. Extend it to a multi-agent research swarm when: the topic has multiple independent sub-questions that can be researched in parallel, or the required depth exceeds what one agent can cover in a reasonable iteration budget. The supervisor pattern (orchestrator + parallel research workers + synthesis) is the natural next step - see the agent supervisor pattern for how to implement it. For adding tracing to this research agent so you can debug and measure each run, see agent tracing and observability.