Build your first AI agent with Python and Claude

Table of Contents

An AI agent is more than a chatbot — it’s a system that can reason, use tools, and complete tasks across multiple steps without constant hand-holding. If you’ve been wanting to build your first AI agent with Python and Claude, this guide walks you through creating a functional web research agent from scratch.

By the end, you’ll have a working agent that can search the web, extract information, and synthesize findings — all through an agentic loop you control.

Understanding the agentic loop

Traditional LLM usage is request-response: you ask, it answers. An agent flips this by running in a loop where the model decides what to do next. Here’s the core pattern:

def agentic_loop(task: str, max_iterations: int = 10):
    messages = [{"role": "user", "content": task}]
    
    for i in range(max_iterations):
        response = call_claude(messages)
        
        if response.stop_reason == "end_turn":
            return response.content  # Task complete
        
        if response.stop_reason == "tool_use":
            tool_results = execute_tools(response.tool_calls)
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
    
    return "Max iterations reached"

The agent keeps running until it either completes the task or hits your safety limit. Claude’s stop_reason tells you whether it wants to use a tool or is done thinking.

Setting up tools for Claude

Tools are functions the agent can call. You define them as JSON schemas, and Claude decides when and how to use them. Here’s a practical setup for a research agent:

import anthropic
import requests

client = anthropic.Anthropic()

tools = [
    {
        "name": "web_search",
        "description": "Search the web for current information on a topic",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "fetch_page",
        "description": "Fetch and extract text content from a URL",
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The URL to fetch"
                }
            },
            "required": ["url"]
        }
    },
    {
        "name": "save_findings",
        "description": "Save research findings to a file",
        "input_schema": {
            "type": "object",
            "properties": {
                "filename": {"type": "string"},
                "content": {"type": "string"}
            },
            "required": ["filename", "content"]
        }
    }
]

Each tool needs a corresponding Python function. Keep them simple and focused:

def web_search(query: str) -> str:
    # Using a search API (DuckDuckGo, Serper, or similar)
    response = requests.get(
        "https://api.search-service.com/search",
        params={"q": query, "limit": 5}
    )
    return response.json()

def fetch_page(url: str) -> str:
    response = requests.get(url, timeout=10)
    # Strip HTML, return plain text (use beautifulsoup or trafilatura)
    return extract_text(response.text)[:4000]  # Limit context size

def save_findings(filename: str, content: str) -> str:
    with open(f"output/{filename}", "w") as f:
        f.write(content)
    return f"Saved to {filename}"

Building the complete research agent

Now let’s wire everything together. The key is properly handling tool calls and feeding results back to Claude:

def execute_tools(tool_calls):
    results = []
    tool_functions = {
        "web_search": web_search,
        "fetch_page": fetch_page,
        "save_findings": save_findings
    }
    
    for tool in tool_calls:
        func = tool_functions.get(tool.name)
        if func:
            result = func(**tool.input)
            results.append({
                "type": "tool_result",
                "tool_use_id": tool.id,
                "content": str(result)
            })
    
    return results

def research_agent(task: str):
    system_prompt = """You are a research agent. Break down research tasks into steps:
    1. Search for relevant information
    2. Fetch promising pages for details  
    3. Synthesize findings
    4. Save the final report
    
    Think step-by-step. Use tools to gather real information before drawing conclusions."""
    
    messages = [{"role": "user", "content": task}]
    
    for iteration in range(15):
        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=4096,
            system=system_prompt,
            tools=tools,
            messages=messages
        )
        
        print(f"Iteration {iteration + 1}: {response.stop_reason}")
        
        if response.stop_reason == "end_turn":
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text
            return "Task completed"
        
        tool_calls = [b for b in response.content if b.type == "tool_use"]
        if tool_calls:
            tool_results = execute_tools(tool_calls)
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": tool_results})
    
    return "Research incomplete - hit iteration limit"

Running your agent on a real task

Test the agent with a concrete research task:

result = research_agent(
    "Research the current state of local LLM deployment options in 2026. "
    "Compare at least 3 popular tools (like Ollama, LM Studio, llama.cpp). "
    "Focus on ease of setup, performance, and supported models. "
    "Save a summary report as 'local-llm-comparison.md'"
)

print(result)

When you run this, you’ll see the agent:

  • Search for “local LLM deployment tools 2026”
  • Fetch pages from relevant results
  • Make additional searches for specific tools
  • Synthesize everything into a comparison
  • Save the final report

The agent makes decisions at each step — it might fetch more pages if initial results are thin, or skip tools it doesn’t need.

Adding guardrails and error handling

Production agents need boundaries. Add these safeguards:

ALLOWED_DOMAINS = ["github.com", "docs.python.org", "arxiv.org"]
MAX_TOOL_CALLS_PER_ITERATION = 3

def fetch_page_safe(url: str) -> str:
    from urllib.parse import urlparse
    domain = urlparse(url).netloc
    
    if not any(allowed in domain for allowed in ALLOWED_DOMAINS):
        return f"Error: Domain {domain} not in allowlist"
    
    try:
        return fetch_page(url)
    except requests.Timeout:
        return "Error: Request timed out"
    except Exception as e:
        return f"Error: {str(e)}"

Also consider: logging every tool call, setting cost limits via token counting, and adding human-in-the-loop confirmation for sensitive actions.

Key takeaways

  • The agentic loop is simple: call model → check if done → execute tools → repeat
  • Tools are just JSON schemas + Python functions: Claude handles the decision-making
  • Always add limits: max iterations, domain allowlists, and error handling prevent runaway agents
  • Start small: get a two-tool agent working before adding complexity

The pattern you’ve built here scales to more complex agents — add more tools, chain multiple agents together, or integrate with databases and APIs. The core loop stays the same.