← Back to Blog

Build an AI Agent with Tool Use from Scratch in Python

The tutorial teaches you to build an AI agent with tool use from scratch in Python, using only the Anthropic SDK. No LangChain, no LangGraph, no abstractions. You write the agent loop, define the tools, and handle the conversation yourself.

Understanding what happens inside agent.run() is the difference between debugging an agent and guessing at one. When something breaks and it will, you need to know which layer the problem lives in: the tool definition, the tool execution, the message history, or the model's reasoning.

This tutorial builds a working agent step by step. By the end you will have a CLI tool that can search the web, do math, and remember what you told it three messages ago.

Prerequisites

  • Python 3.10 or newer (check with python3 --version)
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python functions and dictionaries

What We Are Building

A command-line agent with three tools:

  1. Web search using the Tavily API (free tier: 1,000 searches/month)
  2. Calculator for math expressions
  3. File reader for looking up information from local files

The agent decides which tool to call based on your question, executes it, and uses the result to formulate an answer. If the first tool call does not give enough information, it can call another tool. This is the core loop behind every agent, from Claude Code to ChatGPT plugins.

Step 1: Install Dependencies

mkdir agent-from-scratch && cd agent-from-scratch
python3 -m venv .venv
source .venv/bin/activate

pip install anthropic tavily-python

Set your API keys:

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

The Tavily key is free for the basic tier. Sign up at tavily.com if you do not have one yet.

Step 2: Define Your Tools

Anthropic's tool use API expects a JSON schema for each tool. The model reads these schemas to decide which tool fits the user's question.

# tools.py
"""Tool definitions for the agent."""

import json
import os
from tavily import TavilyClient

tavily = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])

TOOLS = [
    {
        "name": "web_search",
        "description": "Search the web for current information. Use this when you need to look up facts, find recent news, or research a topic.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "calculate",
        "description": "Evaluate a math expression. Use this for calculations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression to evaluate, e.g. '2 + 3 * 4'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "read_file",
        "description": "Read the contents of a local text file. Use this to look up information the user has stored in a file.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the file to read"
                }
            },
            "required": ["path"]
        }
    }
]


def execute_tool(name: str, input_data: dict) -> str:
    """Run a tool by name and return the result as a string."""
    try:
        if name == "web_search":
            result = tavily.search(query=input_data["query"], max_results=3)
            snippets = []
            for r in result.get("results", []):
                snippets.append(f"{r['title']}
{r['url']}
{r['content']}")
            return "

".join(snippets) if snippets else "No results found."

        elif name == "calculate":
            # Safe math eval with restricted builtins
            allowed = {"__builtins__": {}}
            result = eval(input_data["expression"], allowed)
            return str(result)

        elif name == "read_file":
            path = input_data["path"]
            if not os.path.exists(path):
                return f"Error: file not found at {path}"
            with open(path) as f:
                content = f.read()
            # Truncate very long files
            if len(content) > 5000:
                content = content[:5000] + "
... (truncated)"
            return content

        else:
            return f"Unknown tool: {name}"

    except Exception as e:
        return f"Error executing {name}: {str(e)}"

A few design decisions here:

  • The TOOLS list uses Anthropic's tool schema format directly. No wrapper, no framework translation layer. This is what the model sees.
  • execute_tool is a plain function with a switch statement. When you debug a tool failure, you land exactly where the problem is.
  • The calculator uses eval() with empty builtins. For anything beyond a tutorial, use a proper math parser like numexpr or asteval. This keeps the example short.
  • File reads are truncated at 5,000 characters. Sending a 100MB file to the model wastes tokens and hits context limits fast.

Step 3: Build the Agent Loop

This is the heart of it. The loop does three things: send messages to the model, check if the model wants to call a tool, execute it, and repeat until the model produces a text answer.

# agent.py
"""The agent loop. No frameworks, just API calls."""

import anthropic
from tools import TOOLS, execute_tool

client = anthropic.Anthropic()

SYSTEM_PROMPT = """You are a helpful assistant with access to tools. 
Use tools when they help answer the user's question. 
If a tool call fails, explain what went wrong and try a different approach.
Always cite your sources when using web search results."""


def run_agent(user_message: str, history: list[dict] | None = None) -> tuple[str, list[dict]]:
    """Run the agent loop and return (final_answer, updated_history)."""
    if history is None:
        history = []

    # Add the user message
    history.append({"role": "user", "content": user_message})

    # Agent loop: keep going until the model gives a text response
    while True:
        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=TOOLS,
            messages=history
        )

        # Check if the model wants to call tools
        if response.stop_reason == "tool_use":
            # Collect all tool calls from this response
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    result = execute_tool(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result
                    })

            # Add the assistant's response and tool results to history
            history.append({"role": "assistant", "content": response.content})
            history.append({"role": "user", "content": tool_results})
            # Loop continues: model will see tool results and decide next step

        elif response.stop_reason == "end_turn":
            # Model produced a final text answer
            text = ""
            for block in response.content:
                if block.type == "text":
                    text += block.text
            history.append({"role": "assistant", "content": response.content})
            return text, history

        else:
            # Unexpected stop reason
            text = ""
            for block in response.content:
                if block.type == "text":
                    text += block.text
            return text or f"Unexpected stop reason: {response.stop_reason}", history

The loop structure is the same pattern every agent framework uses under the hood:

  1. Send messages to the model
  2. If the model says tool_use, execute the tools and add results back as a user message
  3. If the model says end_turn, extract the text and return it

The key detail is step 2: tool results go into the conversation as a user message with tool_result blocks. This is how Anthropic's API works. The model sees the tool output in its next turn and decides what to do with it.

Notice the while True loop. The model can call multiple tools in sequence before producing a final answer. For example, the user asks "What is the population of Tokyo and what is 15% of that?" The model calls web_search for the population, then calls calculate with the result, then produces a final answer. All in one agent loop.

Step 4: Add the CLI Interface

# main.py
"""CLI interface for the agent."""

from agent import run_agent

def main():
    print("Agent ready. Type 'quit' to exit, 'clear' to reset history.
")
    history = []

    while True:
        try:
            user_input = input("You: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("
Bye.")
            break

        if not user_input:
            continue
        if user_input.lower() == "quit":
            break
        if user_input.lower() == "clear":
            history = []
            print("History cleared.
")
            continue

        answer, history = run_agent(user_input, history)
        print(f"
Agent: {answer}
")

if __name__ == "__main__":
    main()

Step 5: Test It

python main.py

Try these conversations:

You: What is the current price of Bitcoin?
Agent: [searches the web, returns current price with sources]

You: What is 15% of that number?
Agent: [uses the previous context, calculates 15%, returns result]

You: Save that calculation in a file called notes.txt
Agent: [calls read_file to check if the file exists, then responds]

The second question works because the conversation history includes the Bitcoin price from the first answer. The model has enough context to calculate 15% without searching again.

Step 6: Handle Common Failure Modes

Tool call errors

If a tool fails, the model gets the error message and can try something different. The execute_tool function returns error strings instead of raising exceptions, so the model always gets a response.

Context window overflow

Long conversations hit the model's context limit. Here is a simple guard:

def trim_history(history: list[dict], max_messages: int = 40) -> list[dict]:
    """Keep the system prompt context by trimming old messages."""
    # Always keep: first user message (contains initial context)
    # Trim: middle messages when history gets long
    if len(history) <= max_messages:
        return history
    return history[:2] + history[-(max_messages - 2):]

This preserves the first user message (which often contains important context) and the most recent messages. For production, use Anthropic's prompt caching to reduce costs on the parts of the conversation that do not change.

Hallucinated tool calls

Sometimes the model tries to use a tool that does not exist. The API handles this: if the model calls a tool not in your TOOLS list, it raises an error. Catch it and add a message telling the model which tools are available.

try:
    response = client.messages.create(...)
except anthropic.BadRequestError as e:
    if "tool" in str(e).lower():
        # Model tried to use a tool that does not exist
        history.append({"role": "assistant", "content": [
            {"type": "text", "text": f"Tool call failed: {e}. Available tools: web_search, calculate, read_file"}
        ]})
        continue
    raise

When to Use a Framework Instead

This tutorial shows the fundamentals. Frameworks exist because the fundamentals get complicated at scale:

  • LangGraph adds state machines, persistence, human-in-the-loop checkpoints, and parallel tool execution. Use it when your agent has branching logic or needs to survive crashes.
  • Claude Agent SDK gives you pre-built patterns for tool use, multi-turn conversations, and MCP integration. Use it when you want to ship fast and the patterns fit your use case.
  • OpenAI Agents SDK adds routing, guardrails, and handoffs between agents. Use it when you need multi-agent orchestration with safety checks.

The framework choice depends on the shape of your problem, not which one is "best." If your agent can be expressed in 100 lines of Python like this one, the framework is overhead. If you need persistence, retries, parallel execution, or human review, reach for a framework.

Where to Go Next

  • Add prompt caching to reduce costs on repeated system prompts. Anthropic's cache hits cost 90% less than fresh prompts.
  • Add streaming with client.messages.stream() so users see tokens as they are generated instead of waiting for the full response.
  • Add MCP to give your agent access to databases, APIs, and other tools without hardcoding them.
  • Read the full tool use documentation at docs.anthropic.com/en/docs/build-with-claude/tool-use.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation