Skip to main content
AI-Developer/AI Engineering
Part 1 of 16

Build an Autonomous Research Agent with Claude: Web Search, PDF Downloads, and Conversational Memory

Learn to build a fully autonomous AI research agent using the Anthropic Messages API with web search via Tavily, PDF downloading, multi-turn memory, and a Streamlit UI. Step-by-step from zero to production.

March 14, 2026
25 min read
#Anthropic#AI Agents#Tool Use#Tavily#Streamlit#Python#Autonomous Agent

The agent does the work. You get the results.

Last week I spent 3 hours researching 'how the body digests protein.' I read 10 papers, took notes, and synthesized findings. This week I built an autonomous agent that does exactly the same thing in 5 minutes.

Primary Objective
Autonomous Research | Tool Orchestration | Real-Time Execution

The Research Agent Advantage

πŸ€–WHAT THE AGENT DOES
  • Searches the web for research papers (Tavily API)
  • Downloads PDFs automatically
  • Reads and analyzes documents
  • Answers follow-up questions with memory
  • Runs a Streamlit web UI
🧠WHAT YOU'LL LEARN
  • The correct Anthropic Messages API + Tool Use pattern
  • How to implement conversational memory manually
  • How to build the agentic loop (tool β†’ observe β†’ act β†’ repeat)
  • How to add a Streamlit UI with PDF viewer

Clearing Up a Common Misconception

Before writing a single line of code, let's address the most common mistake in "Claude Agent" tutorials.

Fictional vs. Real Anthropic API

❌THIS DOES NOT EXIST
# This code will throw AttributeError
agent = Anthropic().agents.create(
  name="Research Agent",
  memory=True
)
session = agent.create_session()
response = session.query("How...")

The Anthropic Python SDK has NO agents attribute, no create_session(), and no query(). This is a fictional API.

βœ…THE REAL API
# This is what actually works
client = Anthropic()

response = client.messages.create(
  model="claude-sonnet-4-6",
  max_tokens=4096,
  system="You are a research assistant",
  tools=[web_search_tool],
  messages=conversation_history
)

The real pattern: client.messages.create() with a manually managed conversation_history list and a tools array.

The key insight: There is no magic "agent" object. Autonomy comes from a loop you write yourself calling messages.create(), reading the response, executing tools, feeding results back, and repeating until Claude stops requesting tools.


Architecture: The Four Components

The Research Agent System Architecture
graph TD
  User([User Query]) --> AgentLoop[Agentic Loop]
  
  subgraph AgentSystem[The Research Agent]
    SystemPrompt[System Prompt: 'You are an expert...']
    History[(Conversation History)]
    Tools{Tools}
    
    subgraph ToolSet[Capabilities]
      T1[web_search]
      T2[download_pdf]
      T3[read_file]
    end
  end
  
  AgentLoop --> |1. messages.create| Claude[Claude API]
  Claude --> |2. stop_reason: tool_use| AgentLoop
  AgentLoop --> |3. Execute Tool| Tools
  Tools --> |4. Tool Result| AgentLoop
  AgentLoop --> |5. Repeat| Claude
  Claude --> |6. stop_reason: end_turn| FinalResponse([Final Research Summary])

Step 1: Basic Setup and Stateless Queries

Installation

pip install anthropic tavily-python requests streamlit python-dotenv

Create a .env file (add to .gitignore):

ANTHROPIC_API_KEY=sk-ant-...
TAVILY_API_KEY=tvly-...

Your First Working Claude Query

# step1_basic.py
from anthropic import Anthropic
from dotenv import load_dotenv
import os

load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

def query_claude(prompt: str, system: str = "You are a helpful research assistant.") -> str:
    """Single stateless query β€” no memory between calls."""
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=system,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text

if __name__ == "__main__":
    result = query_claude("How does the human body digest protein? Explain the key enzymes involved.")
    print(result)

This works, but has a critical limitation: every call is independent. Ask a follow-up and Claude has no idea what you were discussing. That's where memory comes in.


Step 2: Conversational Memory

Memory in Claude is not a settingβ€”it's a list you maintain. Every turn, you append both the user message and the assistant's response to conversation_history, then pass the entire list on the next API call.

# step2_memory.py
from anthropic import Anthropic
from dotenv import load_dotenv
import os

load_dotenv()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

SYSTEM_PROMPT = """You are an expert research assistant specializing in biology and biochemistry.
When answering questions, be thorough and scientific. Reference previous context in the conversation."""

class ResearchAgent:
    """Research agent with multi-turn conversational memory."""
    def __init__(self, system: str = SYSTEM_PROMPT):
        self.system = system
        self.conversation_history: list[dict] = []

    def chat(self, user_message: str) -> str:
        """Send a message and maintain conversation history."""
        # Add user message to history
        self.conversation_history.append({
            "role": "user",
            "content": user_message
        })

        # Call API with full history
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=self.system,
            messages=self.conversation_history  # Full history every time
        )

        assistant_text = response.content[0].text

        # Add assistant response to history
        self.conversation_history.append({
            "role": "assistant",
            "content": assistant_text
        })
        return assistant_text

if __name__ == "__main__":
    agent = ResearchAgent()
    # Turn 1
    r1 = agent.chat("How does the body digest protein?")
    print(f"Turn 1:\n{r1}\n{'='*60}\n")
    # Turn 2 β€” Claude remembers context
    r2 = agent.chat("What specific enzymes break down the peptide bonds you mentioned?")
    print(f"Turn 2:\n{r2}")
πŸ’‘
Why this works

On Turn 2, you're sending the full history: [user: "How does..."], [assistant: "Protein digestion involves..."], [user: "What specific enzymes..."]. Claude sees everything, so "the enzymes you mentioned" makes perfect sense.


Step 3: Tool Use β€” Web Search and PDF Download

Now we give the agent real capabilities. Claude's tool use works via function calling: you define tool schemas in JSON Schema format, Claude decides when to call them, and you execute the actual code.

Define the Tools

# tools.py
from tavily import TavilyClient
import requests
import os
import json
from dotenv import load_dotenv

load_dotenv()
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))

# --- TOOL SCHEMAS ---
WEB_SEARCH_TOOL = {
    "name": "web_search",
    "description": "Search the web for research papers and scientific articles. Prefer academic sources.",
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {"type": "string", "description": "Search query"},
            "max_results": {"type": "integer", "description": "Number of results (1-10, default 5)"}
        },
        "required": ["query"]
    }
}

DOWNLOAD_PDF_TOOL = {
    "name": "download_pdf",
    "description": "Download a PDF from a URL and save it locally.",
    "input_schema": {
        "type": "object",
        "properties": {
            "url": {"type": "string", "description": "Direct URL to the PDF"},
            "topic": {"type": "string", "description": "Research topic for folder naming"}
        },
        "required": ["url", "topic"]
    }
}

# --- TOOL IMPLEMENTATIONS ---
def execute_web_search(query: str, max_results: int = 5) -> dict:
    try:
        results = tavily.search(query=query, max_results=max_results)
        return {"success": True, "results": results.get("results", [])}
    except Exception as e:
        return {"success": False, "error": str(e)}

def execute_download_pdf(url: str, topic: str) -> dict:
    try:
        folder = os.path.join("papers", topic.replace(" ", "_")[:50])
        os.makedirs(folder, exist_ok=True)
        filename = url.split("/")[-1].split("?")[0] or "paper.pdf"
        filepath = os.path.join(folder, filename)
        
        response = requests.get(url, timeout=30)
        with open(filepath, "wb") as f:
            f.write(response.content)
        return {"success": True, "filepath": filepath}
    except Exception as e:
        return {"success": False, "error": str(e)}

TOOL_REGISTRY = {
    "web_search": execute_web_search,
    "download_pdf": execute_download_pdf
}
ALL_TOOLS = [WEB_SEARCH_TOOL, DOWNLOAD_PDF_TOOL]

The Agentic Loop

This is the core of autonomous behavior. The loop continues as long as Claude's stop_reason is "tool_use":

# step3_agent_with_tools.py
from anthropic import Anthropic
from tools import ALL_TOOLS, TOOL_REGISTRY
import os, json

client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))

RESEARCH_SYSTEM = """You are an autonomous research assistant with access to web search and PDF downloading.
1. Search for recent, authoritative papers
2. Download relevant PDFs
3. Synthesize findings into a clear, cited summary"""

class AutonomousResearchAgent:
    def __init__(self):
        self.conversation_history = []

    def research(self, topic: str) -> str:
        self.conversation_history.append({"role": "user", "content": f"Research: {topic}"})
        
        while True:
            response = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=4096,
                system=RESEARCH_SYSTEM,
                tools=ALL_TOOLS,
                messages=self.conversation_history
            )

            if response.stop_reason == "end_turn":
                final_text = response.content[0].text
                self.conversation_history.append({"role": "assistant", "content": final_text})
                return final_text

            if response.stop_reason == "tool_use":
                self.conversation_history.append({"role": "assistant", "content": response.content})
                
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        result = TOOL_REGISTRY[block.name](**block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": json.dumps(result)
                        })
                
                self.conversation_history.append({"role": "user", "content": tool_results})

Step 4: Streamlit Web Interface

# app.py
import streamlit as st
from step3_agent_with_tools import AutonomousResearchAgent

st.title("πŸ§ͺ Autonomous Research Agent")
if "agent" not in st.session_state:
    st.session_state.agent = AutonomousResearchAgent()

if topic := st.chat_input("Enter research topic..."):
    with st.chat_message("user"): st.write(topic)
    with st.chat_message("assistant"):
        with st.spinner("Researching..."):
            result = st.session_state.agent.research(topic)
            st.markdown(result)

Security and Best Practices

Production Readiness Checklist

API SAFETY
  • Use python-dotenv for keys
  • Add .env to .gitignore
  • Rotate keys if compromised
LOOP SAFETY
  • Add max_iterations limit
  • Validate tool inputs
  • Sanitize filenames
COST CONTROL
  • Monitor token usage
  • Use Haiku for summaries
  • Cache search results

Key Takeaways

01
01
Agents are loops, not objects

Autonomy is created by the while-loop you write to handle tool requests.

01
01
History is manually managed

You must append both assistant and user (tool_result) messages to keep Claude in context.

01
01
Tools need clear schemas

Claude decides to use a tool based entirely on the description you provide in the schema.

AI Engineering
MH

Mohamed Hamed

20 years building production systems β€” the last several deep in AI integration, LLMs, and full-stack architecture. I write what I've actually built and broken. If this was useful, the next one goes to LinkedIn first.

Follow on LinkedIn β†’