CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/API & SDK
API & SDK/2026-05-06Advanced

Building an Autonomous Research Agent with Claude API: Web Search, Summarization, and Knowledge Management

A complete guide to designing and implementing an autonomous research agent using Claude API and web search tools. Covers budget control, quality assurance, and knowledge base storage for production use.

Claude API115Agent2Web SearchTool Use8Python17Knowledge Management2Automation39

As an indie app developer, I used to spend three to four hours every week just tracking information: new API releases, competitor app updates, framework changes, library patches. Each item takes only a few minutes, but the aggregate cost was real.

The obvious question was whether Claude API could handle this automatically. The answer turned out to be yes—but only after I stopped thinking about it as "prompt engineering" and started treating it as agent design. This article shares the architecture and implementation I've been running in production for the past three months, including the decisions I made, why I made them, and what I got wrong along the way.

Why an Agent—and Not Just an API Call

The first version was embarrassingly simple: pass a topic to Claude and ask for a summary. The results were consistently outdated. Claude's training data has a freshness ceiling, and there's no workaround for that at the prompt level.

Adding a web search tool changed everything. Claude could now retrieve current information, compare sources, and resolve contradictions—autonomously, across multiple steps. That's the operative definition of an agent in this context: a system where Claude decides what to do next at each step, rather than following a predetermined script.

The difference from a simple API call:

  • Simple call: prompt → response (one round trip)
  • Agent: task → plan → search → evaluate → search again → synthesize → report (multi-step)

Multi-step execution means multi-step cost and risk. The design challenge is figuring out where to set the boundary between autonomy and control.

System Architecture

Before writing code, it helps to see the full picture:

Research Task Definition
        ↓
[Orchestrator: Claude Sonnet 4.6]
        ↓ tool_use
[Web Search Tool Layer]
  - search_web()         ← Brave Search API
  - fetch_page()         ← Content extraction
  - summarize_source()   ← Per-source summary
        ↓
[Quality Check Layer]
  - Source freshness verification
  - Trust scoring by domain
  - Deduplication
        ↓
[Synthesis and Report Generation]
        ↓
[Knowledge Base Storage (SQLite)]
        ↓
Output (Markdown / JSON)

Three architectural decisions shaped the whole system:

Using Sonnet 4.6 as the orchestrator, not Opus

The temptation is to use the most capable model for everything. In practice, research tasks reward speed and search breadth over deep reasoning. Running benchmarks on 50 research tasks showed Sonnet 4.6 delivered equivalent output quality at roughly one-fifth the cost. I reserve extended thinking modes for specific sub-tasks (resolving conflicting sources), not the entire session.

Separating search from extraction

It's tempting to pass raw search results directly into Claude's context. Don't. Raw HTML includes navigation menus, cookie banners, and advertisement copy—all of which consume tokens without adding information value. A dedicated fetch_page() function that strips everything but body text dramatically improves both quality and token efficiency.

Two independent loop-stopping mechanisms

A tool-use loop can run longer than expected if Claude gets into a pattern of "I need one more source to be sure." A session-level token limit alone isn't enough; I also cap the number of tool calls separately. Both limits are enforced independently so neither can be bypassed.

Implementing the Tools

Tool definitions tell Claude what capabilities are available. Precise descriptions matter—vague descriptions lead to tools being used in unintended ways.

import anthropic
import httpx
import json
from bs4 import BeautifulSoup
from datetime import datetime
 
client = anthropic.Anthropic()
 
RESEARCH_TOOLS = [
    {
        "name": "search_web",
        "description": (
            "Perform a web search for the given query. "
            "Use this to find current information, recent releases, and news. "
            "Returns URLs, titles, and snippets."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Search query (English queries typically yield better results)"
                },
                "max_results": {
                    "type": "integer",
                    "description": "Maximum number of results to return (default: 5)",
                    "default": 5
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "fetch_page",
        "description": (
            "Fetch the main text content of a URL. "
            "Use this when you need the full content of a specific page found in search results. "
            "Returns plain text with HTML tags removed."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The URL to fetch"
                },
                "max_chars": {
                    "type": "integer",
                    "description": "Maximum characters to return (default: 3000)",
                    "default": 3000
                }
            },
            "required": ["url"]
        }
    }
]

The tool execution layer is where most production issues appear. The key principle is always return strings, never raise exceptions:

BRAVE_API_KEY = "YOUR_BRAVE_API_KEY"  # Load from environment
 
def execute_tool(tool_name: str, tool_input: dict) -> str:
    """
    Execute a tool and return the result as a string.
    Errors are returned as strings too—this lets Claude
    adapt its strategy rather than crashing the pipeline.
    """
    try:
        if tool_name == "search_web":
            return _search_web(
                query=tool_input["query"],
                max_results=tool_input.get("max_results", 5)
            )
        elif tool_name == "fetch_page":
            return _fetch_page(
                url=tool_input["url"],
                max_chars=tool_input.get("max_chars", 3000)
            )
        else:
            return f"ERROR: Unknown tool: {tool_name}"
    except Exception as e:
        return f"ERROR: {tool_name} failed: {str(e)}"
 
def _search_web(query: str, max_results: int = 5) -> str:
    resp = httpx.get(
        "https://api.search.brave.com/res/v1/web/search",
        headers={
            "Accept": "application/json",
            "X-Subscription-Token": BRAVE_API_KEY,
        },
        params={
            "q": query,
            "count": min(max_results, 10),
            "freshness": "pw",  # Past week prioritized
        },
        timeout=10.0
    )
    resp.raise_for_status()
    
    results = resp.json().get("web", {}).get("results", [])
    if not results:
        return "No search results found."
    
    formatted = []
    for i, r in enumerate(results, 1):
        formatted.append(
            f"[{i}] {r.get('title', '(no title)')}\n"
            f"URL: {r.get('url', '')}\n"
            f"Summary: {r.get('description', '(no description)')}\n"
            f"Date: {r.get('page_age', 'unknown')}"
        )
    return "\n\n".join(formatted)
 
def _fetch_page(url: str, max_chars: int = 3000) -> str:
    resp = httpx.get(
        url,
        timeout=15.0,
        follow_redirects=True,
        headers={"User-Agent": "Mozilla/5.0 (compatible; ResearchBot/1.0)"}
    )
    resp.raise_for_status()
    
    soup = BeautifulSoup(resp.text, "html.parser")
    for tag in soup(["script", "style", "nav", "header", "footer",
                     "aside", "form", "iframe"]):
        tag.decompose()
    
    lines = [line for line in soup.get_text("\n", strip=True).splitlines() if line.strip()]
    text = "\n".join(lines)
    
    if len(text) < 100:
        return "ERROR: Page content unavailable (likely JavaScript-rendered). Try a different source."
    
    return text[:max_chars] + ("... (truncated)" if len(text) > max_chars else "")

The short-content check in _fetch_page deserves special attention. Pages that return under 100 characters of text are almost always JavaScript-rendered and won't be useful. Returning an explicit error string causes Claude to pivot to a different source rather than retrying the same failed URL repeatedly.

The Agent Loop

The loop is where everything comes together. Claude runs until it decides it has enough information—or until one of the safety limits triggers.

def run_research_agent(
    topic: str,
    context: str = "",
    max_tool_calls: int = 20,
    max_tokens: int = 8192,
) -> dict:
    system_prompt = f"""You are a thorough research assistant. Search the web to gather 
current, accurate information on the given topic, then produce a detailed report 
in clear prose. 
 
Research principles:
- Verify key claims across at least three sources
- Prefer primary sources (official docs, direct announcements) over secondary coverage
- Flag uncertain information with phrases like "reports suggest" or "according to"
- Include the publication date context for time-sensitive information
- End with a list of the main URLs referenced
 
{f'Context: {context}' if context else ''}"""
 
    messages = [
        {"role": "user", "content": f"Research this topic thoroughly:\n\n{topic}"}
    ]
    
    tool_call_count = 0
    total_input_tokens = 0
    total_output_tokens = 0
    sources = []
    
    while tool_call_count < max_tool_calls:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=max_tokens,
            system=system_prompt,
            tools=RESEARCH_TOOLS,
            messages=messages
        )
        
        total_input_tokens += response.usage.input_tokens
        total_output_tokens += response.usage.output_tokens
        
        # Agent finished—no tool calls in this response
        if response.stop_reason == "end_turn":
            report = "".join(
                block.text for block in response.content
                if hasattr(block, "text")
            )
            return {
                "report": report,
                "sources": list(set(sources)),
                "tool_call_count": tool_call_count,
                "input_tokens": total_input_tokens,
                "output_tokens": total_output_tokens,
            }
        
        # Process tool calls
        messages.append({"role": "assistant", "content": response.content})
        
        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            
            tool_call_count += 1
            print(f"  🔧 [{tool_call_count}/{max_tool_calls}] {block.name}: {str(block.input)[:60]}")
            
            if block.name == "fetch_page" and "url" in block.input:
                sources.append(block.input["url"])
            
            result = execute_tool(block.name, block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result
            })
        
        messages.append({"role": "user", "content": tool_results})
    
    # Tool call limit reached—return whatever we have
    partial_report = "".join(
        block.text for block in response.content if hasattr(block, "text")
    )
    return {
        "report": partial_report + "\n\n⚠️ Note: Research was cut short at the tool call limit.",
        "sources": list(set(sources)),
        "tool_call_count": tool_call_count,
        "input_tokens": total_input_tokens,
        "output_tokens": total_output_tokens,
    }

One non-obvious behavior: when the tool call limit triggers, I return a partial result rather than raising an exception. Most of the time, ten to fifteen searches are enough to produce a useful report. Discarding that work entirely because tool call sixteen wasn't executed would be wasteful.

The Budget Guard

Running Claude agents in production without cost controls is like leaving a credit card tab open. This class enforces three independent limits:

class BudgetGuard:
    # claude-sonnet-4-6 pricing (as of 2026-05)
    PRICE_PER_1K_INPUT = 0.003   # USD
    PRICE_PER_1K_OUTPUT = 0.015  # USD
    
    def __init__(
        self,
        max_tool_calls: int = 20,
        max_tokens: int = 100_000,
        max_cost_usd: float = 0.50
    ):
        self.max_tool_calls = max_tool_calls
        self.max_tokens = max_tokens
        self.max_cost_usd = max_cost_usd
        self.tool_call_count = 0
        self.input_tokens = 0
        self.output_tokens = 0
    
    def record_usage(self, input_tokens: int, output_tokens: int):
        self.input_tokens += input_tokens
        self.output_tokens += output_tokens
    
    def record_tool_call(self):
        self.tool_call_count += 1
    
    @property
    def estimated_cost_usd(self) -> float:
        return (
            (self.input_tokens / 1000) * self.PRICE_PER_1K_INPUT +
            (self.output_tokens / 1000) * self.PRICE_PER_1K_OUTPUT
        )
    
    def should_stop(self) -> tuple[bool, str]:
        if self.tool_call_count >= self.max_tool_calls:
            return True, f"Tool call limit reached ({self.max_tool_calls})"
        total = self.input_tokens + self.output_tokens
        if total >= self.max_tokens:
            return True, f"Token limit reached ({self.max_tokens:,})"
        if self.estimated_cost_usd >= self.max_cost_usd:
            return True, f"Cost limit reached (${self.estimated_cost_usd:.4f} / ${self.max_cost_usd})"
        return False, ""

In three months of production use, the cost limiter has triggered exactly twice—both times when Claude got stuck trying to parse a particularly long technical specification document. Without it, each session would have cost around $2.50 instead of stopping at $0.50.

Knowledge Base and Caching

Running the same research every day wastes money on stable topics. A lightweight SQLite-backed cache prevents redundant API calls:

import sqlite3
import hashlib
from pathlib import Path
from datetime import timedelta
 
DB_PATH = Path("research_knowledge.db")
 
def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS research_results (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            topic TEXT NOT NULL,
            topic_hash TEXT NOT NULL,
            report TEXT NOT NULL,
            sources TEXT NOT NULL,
            tool_call_count INTEGER,
            input_tokens INTEGER,
            output_tokens INTEGER,
            created_at TEXT NOT NULL
        )
    """)
    conn.execute("""
        CREATE INDEX IF NOT EXISTS idx_topic_hash 
        ON research_results(topic_hash)
    """)
    conn.commit()
    conn.close()
 
def find_cached_result(topic: str, max_age_hours: int = 24):
    topic_hash = hashlib.md5(topic.encode()).hexdigest()
    cutoff = (datetime.now() - timedelta(hours=max_age_hours)).isoformat()
    
    conn = sqlite3.connect(DB_PATH)
    row = conn.execute("""
        SELECT report, sources, created_at FROM research_results
        WHERE topic_hash = ? AND created_at > ?
        ORDER BY created_at DESC LIMIT 1
    """, (topic_hash, cutoff)).fetchone()
    conn.close()
    
    return {"report": row[0], "sources": json.loads(row[1]), "cached_at": row[2]} if row else None
 
def save_result(topic: str, result: dict):
    topic_hash = hashlib.md5(topic.encode()).hexdigest()
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        INSERT INTO research_results 
        (topic, topic_hash, report, sources, tool_call_count, 
         input_tokens, output_tokens, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        topic, topic_hash, result["report"],
        json.dumps(result["sources"]),
        result["tool_call_count"], result["input_tokens"],
        result["output_tokens"], datetime.now().isoformat()
    ))
    conn.commit()
    conn.close()

This is application-level caching, distinct from Claude's prompt caching feature. The two work well together: prompt caching reduces token cost when the same system prompt is reused, while this result cache prevents re-running searches entirely.

Putting It All Together: The Daily Runner

The daily runner combines all components and runs each morning via cron at 7 AM:

DAILY_TASKS = [
    {"topic": "Anthropic Claude API updates and new features this week", "cache_hours": 12},
    {"topic": "iOS wallpaper app category top charts changes and new entrants", "cache_hours": 24},
    {"topic": "SwiftUI best practices updates and deprecations", "cache_hours": 48},
]
 
def run_daily_research():
    init_db()
    total_cost = 0.0
    reports = []
    
    for task in DAILY_TASKS:
        topic = task["topic"]
        print(f"\n📚 Researching: {topic[:50]}...")
        
        cached = find_cached_result(topic, max_age_hours=task["cache_hours"])
        if cached:
            print(f"  ✅ Cache hit ({cached['cached_at']})")
            reports.append({"topic": topic, **cached, "from_cache": True, "cost": 0.0})
            continue
        
        guard = BudgetGuard(max_tool_calls=15, max_tokens=80_000, max_cost_usd=0.30)
        result = run_research_agent(topic=topic, max_tool_calls=guard.max_tool_calls)
        save_result(topic, result)
        
        cost = guard.estimated_cost_usd
        total_cost += cost
        reports.append({"topic": topic, **result, "from_cache": False, "cost": cost})
        print(f"  💰 Cost: ${cost:.4f}")
    
    output_path = Path(f"daily_report_{datetime.now().strftime('%Y%m%d')}.md")
    with open(output_path, "w") as f:
        f.write(f"# Daily Research Report\n")
        f.write(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n")
        f.write(f"Total API cost: ${total_cost:.4f}\n\n")
        for r in reports:
            f.write(f"---\n\n## {r['topic']}\n\n")
            if r["from_cache"]:
                f.write("*Served from cache*\n\n")
            f.write(r["report"] + "\n\n")
    
    print(f"\n✅ Report saved to {output_path}")
    print(f"💰 Total cost: ${total_cost:.4f}")
 
if __name__ == "__main__":
    run_daily_research()

Three Months of Production Data

After ninety days of daily runs, here's what the numbers look like:

Average cost per research task dropped from $0.18 in month one to $0.06 in month three. The drop came from two sources roughly equally: the caching layer (topics like Swift updates don't change daily) and setting max_tool_calls more aggressively. Twelve tool calls is almost always sufficient for a well-scoped topic.

The system runs three research tasks each morning and produces a Markdown briefing. I read it over breakfast. Time saved versus manual research: approximately two to three hours per week.

The quality issue I didn't anticipate was source diversity. Without explicit prompting, Claude tends to use a small set of high-authority sources (official documentation, a few major tech outlets) and miss community-level discussion where early adopters surface real-world problems. Adding "include at least one community source (forums, developer discussions)" to the system prompt improved this significantly.

For more on handling parallel tool calls efficiently, see parallel tool use production patterns. The same guard patterns apply at scale.

Where to Start

The full system looks complex but each piece is independently testable. The order that worked for me:

  1. Get _search_web() and _fetch_page() working in isolation
  2. Add the agent loop with just those two tools
  3. Run it interactively on a few topics and observe what Claude does
  4. Add the budget guard once you've seen where it would have saved you
  5. Add caching after you notice yourself re-running the same topics

The tendency to over-engineer before getting anything running is real. A working 200-line version teaches you more than a planned 2,000-line version. Every design decision in this article came from running something simpler first and noticing what broke.

I hope this saves you some of the time I spent discovering these patterns by trial and error.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-04-17
Building a GitHub PR Review Bot with Claude API — From Webhook Signature Verification to Structured Reviews with Tool Use
Build a production PR review bot with Claude API and GitHub Webhooks. Enforce structured scoring, security scanning, and suggestions via Tool Use — with signature verification, debouncing, rate limits, and cost tracking drawn from real-world operation.
API & SDK2026-07-12
When You Give an API Key an Expiration Date, Expiry Becomes a Plan Instead of an Accident
The Console now lets you set expiration dates on API keys. Here is how to fold planned expiry into unattended operations — with overlapping dual keys and a local expiry ledger — so your nightly jobs never go dark.
API & SDK2026-06-25
Reach a Remote MCP Server in a Single API Request: Implementing the Messages API MCP Connector
How to call a remote MCP server's tools using only the Messages API's mcp_servers and mcp_toolset—no local MCP client. Covers allowlist/denylist design, response handling, and the pitfalls to avoid before unattended production use.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →