🔬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.
mkdir research-agent
cd research-agent
npm init -y
npm install @anthropic-ai/sdk zod
npm install -D typescript @types/node tsx
npx tsc --initUpdate tsconfig.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:
{
"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.
// 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.
// 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.
// 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
# 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:
- Initial prompt -- We send Claude the research plan and ask it to start.
- Tool call -- Claude responds with a
tool_useblock requesting a web search. - Tool execution -- Our code executes the search and sends results back.
- Analysis -- Claude reads the results and decides what to do next.
- 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:
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:
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:
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.