HomeAgents & OrchestrationProject: Autonomous Research Agent
advanced20 min read· Module 9, Lesson 6

🔬Project: Autonomous Research Agent

Build an agent that plans research, searches the web, and writes reports

Project: Autonomous Research Agent

In this project you will build a fully autonomous research agent. Given a question the agent will create a research plan, search the web for information, synthesize findings, and produce a structured report with citations. This is a real agentic loop: Claude decides which tool to call, interprets the results, and loops until it has enough information.


What We Are Building

An Autonomous Research Agent that:

  • Accepts a research question from the user
  • Creates a multi-step research plan
  • Uses a web search tool to gather information
  • Evaluates whether enough data has been collected
  • Synthesizes all findings into a structured report
  • Includes proper citations for every claim

Architecture Overview

User Question | v [Research Planner] -- Claude creates search queries | v [Agent Loop] |---> Call web_search tool |---> Claude reads results |---> Decides: enough info? --> No --> loop again | --> Yes --> proceed v [Report Generator] -- Claude writes structured report | v Structured JSON Report with citations

Step 1: Project Setup

Create the project directory and install dependencies.

Terminal
mkdir research-agent cd research-agent npm init -y npm install @anthropic-ai/sdk zod npm install -D typescript @types/node tsx npx tsc --init

Update tsconfig.json:

JSON
{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, "outDir": "./dist", "rootDir": "./src", "resolveJsonModule": true, "declaration": true }, "include": ["src/**/*"] }

Update package.json to add a start script and set the module type:

JSON
{ "type": "module", "scripts": { "start": "tsx src/agent.ts" } }

Step 2: Define Types and Schemas

Create src/types.ts with all the types our agent will use.

TypeScript
// src/types.ts export interface SearchResult { title: string; url: string; snippet: string; } export interface ResearchFinding { query: string; source: string; url: string; keyPoints: string[]; } export interface Citation { id: number; title: string; url: string; accessedAt: string; } export interface ReportSection { heading: string; body: string; citations: number[]; // references Citation.id } export interface ResearchReport { title: string; question: string; summary: string; sections: ReportSection[]; citations: Citation[]; generatedAt: string; } export interface ResearchPlan { queries: string[]; reasoning: string; }

Step 3: Build the Web Search Tool

Create src/tools.ts. This defines the tool schema that Claude will call. In production you would connect to a real search API. Here we include a simulated search for demonstration, plus instructions for plugging in a real provider.

TypeScript
// src/tools.ts // Tool definition for Claude export const webSearchTool: Anthropic.Tool = { name: "web_search", description: "Search the web for information on a given query. " + "Returns a list of results with title, URL, and snippet. " + "Use this to gather information for research.", input_schema: { type: "object" as const, properties: { query: { type: "string", description: "The search query to look up on the web", }, num_results: { type: "number", description: "Number of results to return (default 5, max 10)", }, }, required: ["query"], }, }, // Simulated web search for demonstration // Replace this function with a real search API call in production // (e.g., Brave Search API, SerpAPI, Tavily, or Google Custom Search) export async function executeWebSearch( query: string, numResults: number = 5 ): Promise<SearchResult[]> { console.log(` [Tool] Searching: "${query}"`); // --------------------------------------------------- // PRODUCTION: Replace the block below with a real API. // Example with Brave Search: // // const response = await fetch( // `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${numResults}`, // { headers: { "X-Subscription-Token": process.env.BRAVE_API_KEY! } } // ); // const data = await response.json(); // return data.web.results.map((r: any) => ({ // title: r.title, // url: r.url, // snippet: r.description, // })); // --------------------------------------------------- // Simulated results for demonstration const simulatedResults: SearchResult[] = Array.from( { length: Math.min(numResults, 5) }, (_, i) => ({ title: `Result ${i + 1} for: ${query}`, url: `https://example.com/article-${i + 1}?q=${encodeURIComponent(query)}`, snippet: `This is a detailed snippet about ${query}. It contains relevant information that the research agent can use to build its report. Key finding ${i + 1} related to the topic.`, }) ); return simulatedResults; }

Step 4: Build the Agent Loop

This is the core of the project. Create src/agent.ts.

TypeScript
// src/agent.ts ResearchFinding, ResearchReport, ResearchPlan, } from "./types.js"; const client = new Anthropic(); const MODEL = "claude-sonnet-4-6"; // ------------------------------------------------------- // Phase 1: Create a research plan // ------------------------------------------------------- async function createResearchPlan( question: string ): Promise<ResearchPlan> { console.log("\n[Phase 1] Creating research plan...\n"); const response = await client.messages.create({ model: MODEL, max_tokens: 1024, messages: [ { role: "user", content: `You are a research planning assistant. Given the following research question, create a list of 3-5 specific web search queries that would help answer it comprehensively. Research question: "${question}" Respond with ONLY valid JSON in this exact format: { "reasoning": "Brief explanation of your search strategy", "queries": ["query 1", "query 2", "query 3"] } Do not include any other text outside the JSON.`, }, ], }); const text = response.content[0].type === "text" ? response.content[0].text : ""; try { const plan: ResearchPlan = JSON.parse(text); console.log(` Strategy: ${plan.reasoning}`); console.log(` Queries planned: ${plan.queries.length}`); plan.queries.forEach((q, i) => console.log(` ${i + 1}. ${q}`) ); return plan; } catch { // Fallback: extract JSON from the response const match = text.match(/\{[\s\S]*\}/); if (match) { return JSON.parse(match[0]); } // Last resort: use the question itself return { reasoning: "Using direct question as search query", queries: [question], }; } } // ------------------------------------------------------- // Phase 2: Execute research with agentic tool-use loop // ------------------------------------------------------- async function executeResearch( question: string, plan: ResearchPlan ): Promise<ResearchFinding[]> { console.log("\n[Phase 2] Executing research via agent loop...\n"); const findings: ResearchFinding[] = []; const systemPrompt = `You are an autonomous research agent. Your job is to search the web and gather comprehensive information to answer a research question. You have access to a web_search tool. Use it to search for information. Research question: "${question}" Instructions: 1. Execute the planned search queries one at a time. 2. After each search, analyze the results carefully. 3. Extract key findings from each result. 4. When you have gathered enough information from all queries, respond with DONE. Planned queries: ${plan.queries.map((q, i) => `${i + 1}. ${q}`).join("\n")} After each tool call, briefly summarize what you found. When all queries are complete, say exactly "DONE" as the first word of your message.`; const messages: Anthropic.MessageParam[] = [ { role: "user", content: "Begin the research. Start with the first query.", }, ]; const MAX_ITERATIONS = 15; let iteration = 0; while (iteration < MAX_ITERATIONS) { iteration++; console.log(` --- Iteration ${iteration} ---`); const response = await client.messages.create({ model: MODEL, max_tokens: 4096, system: systemPrompt, tools: [webSearchTool], messages, }); // Check if the model wants to use a tool if (response.stop_reason === "tool_use") { const assistantContent = response.content; messages.push({ role: "assistant", content: assistantContent }); const toolResults: Anthropic.ToolResultBlockParam[] = []; for (const block of assistantContent) { if (block.type === "tool_use") { const input = block.input as { query: string; num_results?: number; }; const searchResults = await executeWebSearch( input.query, input.num_results ?? 5 ); // Store findings for (const result of searchResults) { findings.push({ query: input.query, source: result.title, url: result.url, keyPoints: [result.snippet], }); } toolResults.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(searchResults, null, 2), }); } } messages.push({ role: "user", content: toolResults }); } else { // Model responded with text (no tool call) const textBlock = response.content.find( (b) => b.type === "text" ); const text = textBlock?.type === "text" ? textBlock.text : ""; messages.push({ role: "assistant", content: response.content, }); if (text.startsWith("DONE") || text.includes("DONE")) { console.log("\n Agent signaled completion.\n"); break; } // Prompt the agent to continue messages.push({ role: "user", content: "Continue with the next search query, or say DONE if you have gathered enough information.", }); } } if (iteration >= MAX_ITERATIONS) { console.log(" Reached max iterations, proceeding to report."); } console.log(` Total findings collected: ${findings.length}`); return findings; } // ------------------------------------------------------- // Phase 3: Generate structured report // ------------------------------------------------------- async function generateReport( question: string, findings: ResearchFinding[] ): Promise<ResearchReport> { console.log("[Phase 3] Generating structured report...\n"); const findingsSummary = findings .map( (f, i) => `[${i + 1}] Source: ${f.source} URL: ${f.url} Query: ${f.query} Key Points: ${f.keyPoints.join("; ")}` ) .join("\n\n"); const response = await client.messages.create({ model: MODEL, max_tokens: 4096, messages: [ { role: "user", content: `You are a research report writer. Based on the findings below, write a comprehensive, well-structured research report. Research Question: "${question}" Findings: ${findingsSummary} Respond with ONLY valid JSON in this exact format: { "title": "Report title", "question": "${question}", "summary": "A 2-3 sentence executive summary", "sections": [ { "heading": "Section title", "body": "Detailed content for this section. Reference citations using [1], [2], etc.", "citations": [1, 2] } ], "citations": [ { "id": 1, "title": "Source title", "url": "https://...", "accessedAt": "${new Date().toISOString()}" } ], "generatedAt": "${new Date().toISOString()}" } Guidelines: - Include 3-5 report sections - Every factual claim must reference a citation - The summary should be concise but informative - Write in a professional, objective tone - Do not include any text outside the JSON`, }, ], }); const text = response.content[0].type === "text" ? response.content[0].text : ""; try { return JSON.parse(text); } catch { const match = text.match(/\{[\s\S]*\}/); if (match) { return JSON.parse(match[0]); } throw new Error("Failed to parse report JSON from Claude response"); } } // ------------------------------------------------------- // Phase 4: Display the report // ------------------------------------------------------- function displayReport(report: ResearchReport): void { console.log("\n" + "=".repeat(60)); console.log(" RESEARCH REPORT"); console.log("=".repeat(60)); console.log(`\nTitle: ${report.title}`); console.log(`Question: ${report.question}`); console.log(`Generated: ${report.generatedAt}`); console.log(`\n--- Summary ---\n${report.summary}`); for (const section of report.sections) { console.log(`\n--- ${section.heading} ---`); console.log(section.body); if (section.citations.length > 0) { console.log( ` [Citations: ${section.citations.join(", ")}]` ); } } console.log("\n--- References ---"); for (const citation of report.citations) { console.log( ` [${citation.id}] ${citation.title}` ); console.log(` ${citation.url}`); } console.log("\n" + "=".repeat(60)); } // ------------------------------------------------------- // Main: tie it all together // ------------------------------------------------------- async function main(): Promise<void> { const question = process.argv[2] ?? "What are the latest advances in quantum computing and how might they impact cryptography?"; console.log("=".repeat(60)); console.log(" AUTONOMOUS RESEARCH AGENT"); console.log("=".repeat(60)); console.log(`\nResearch Question: ${question}\n`); try { // Phase 1: Plan const plan = await createResearchPlan(question); // Phase 2: Research (agentic loop) const findings = await executeResearch(question, plan); // Phase 3: Generate report const report = await generateReport(question, findings); // Phase 4: Display displayReport(report); // Also save the raw JSON report const reportJson = JSON.stringify(report, null, 2); const fs = await import("fs"); fs.writeFileSync("report.json", reportJson); console.log("\nFull report saved to report.json"); } catch (error) { console.error("Research agent failed:", error); process.exit(1); } } main();

Step 5: Run the Agent

Terminal
# Set your API key export ANTHROPIC_API_KEY="your-key-here" # Run with default question npm start # Run with a custom question npm start -- "What is the current state of fusion energy research?"

Understanding the Agent Loop

The heart of this project is the agentic loop in executeResearch. Here is what happens step by step:

  1. Initial prompt -- We send Claude the research plan and ask it to start.
  2. Tool call -- Claude responds with a tool_use block requesting a web search.
  3. Tool execution -- Our code executes the search and sends results back.
  4. Analysis -- Claude reads the results and decides what to do next.
  5. Loop or exit -- Claude either calls another tool or says "DONE".

This is the standard pattern for all agentic tool-use with Claude:

while (not done) { response = claude.messages.create({ tools, messages }) if response.stop_reason === "tool_use": execute the tool append tool results to messages else: check if agent is done if not done, prompt to continue }

Key design decisions:

  • Max iterations guard -- Prevents infinite loops. Always set a ceiling.
  • Conversation history -- Every tool call and result is appended to messages so Claude has full context.
  • Structured exit signal -- The agent says "DONE" to signal completion, giving clean control flow.

Adding a Real Search API

To use a real search API, replace the executeWebSearch function in src/tools.ts. Here is an example using the Brave Search API:

TypeScript
export async function executeWebSearch( query: string, numResults: number = 5 ): Promise<SearchResult[]> { const apiKey = process.env.BRAVE_API_KEY; if (!apiKey) { throw new Error("BRAVE_API_KEY environment variable is required"); } const url = new URL("https://api.search.brave.com/res/v1/web/search"); url.searchParams.set("q", query); url.searchParams.set("count", String(numResults)); const response = await fetch(url.toString(), { headers: { Accept: "application/json", "X-Subscription-Token": apiKey, }, }); if (!response.ok) { throw new Error(`Search API error: ${response.status}`); } const data = await response.json(); return (data.web?.results ?? []).map((r: any) => ({ title: r.title, url: r.url, snippet: r.description, })); }

Extending the Agent

Here are some ideas to extend this project:

1. Add a read-page tool -- Let the agent fetch and read full web pages, not just snippets:

TypeScript
const readPageTool: Anthropic.Tool = { name: "read_page", description: "Fetch and read the full text content of a web page URL", input_schema: { type: "object" as const, properties: { url: { type: "string", description: "The URL to fetch" }, }, required: ["url"], }, },

2. Add extended thinking -- Enable Claude to reason more deeply before deciding the next step:

TypeScript
const response = await client.messages.create({ model: MODEL, max_tokens: 16000, thinking: { type: "enabled", budget_tokens: 8000, }, tools: [webSearchTool], messages, });

3. Save and resume research sessions -- Serialize the message history to disk so you can resume a research session later.

4. Multi-agent review -- After the report is generated, send it to a second Claude instance that acts as a reviewer, checking for accuracy and completeness.


What You Learned

In this project you built a complete autonomous research agent that demonstrates:

  • Agentic tool-use loops -- The fundamental pattern for building AI agents with Claude
  • Research planning -- Using Claude to decompose a question into search queries
  • Tool integration -- Defining tools, executing them, and feeding results back
  • Structured output -- Getting Claude to produce well-formatted JSON reports
  • Error handling -- Graceful fallbacks when parsing fails
  • Iteration guards -- Preventing runaway agent loops

This pattern is the foundation for building any kind of agent: customer support bots, data analysis pipelines, code generation systems, and more. The key insight is that Claude decides when to use tools and when it has enough information -- you provide the tools and the guardrails.