Build a Multi-Tool AI Agent

Build an AI agent that autonomously chains tools to solve complex problems.

Introduction

30 Second Summary

Every AI assistant that can browse the web, run code, or search your files follows the same hidden pattern: a loop that decides which tool to use, calls it, reads the result, and repeats until the job is done.

In this project, you will build a multi-tool AI agent from scratch using Claude's tool use API. You will implement the entire orchestration layer yourself: tool definitions, the agentic loop, tool dispatching, and error handling.

What You'll Build

A Python-based AI agent that autonomously decides which tools to call, executes them, and chains results together to answer complex multi-step questions.

By the end of this project, you'll have:

  • A working multi-tool AI agent that orchestrates a calculator, Wikipedia searcher, and Python code executor to solve problems autonomously.
  • A deep understanding of the agentic loop pattern at the API level, including tool_use blocks, tool_result blocks, and stop_reason routing.
  • A production-ready tool dispatcher that handles parallel calls, errors, and iteration limits.
  • Secret Mission: Add a file read/write tool and persistent conversation memory so the agent can save and recall information across runs.

Are there any prerequisites?

You should have basic Python knowledge (variables, functions, loops) and some familiarity with LLM concepts.

No prior experience with AI APIs or tool use is required.

Define Your Tools

Your environment is ready and the Anthropic SDK is connected. Now it's time to give your agent its capabilities.

An AI agent without tools is just a chatbot. Tools are the functions that let Claude take action in the real world: calculating numbers, fetching information, running code. In this step, you'll define three tools and implement the Python functions that power them.

In this step, get ready to:
  • Define three tool schemas that tell Claude what each tool does and what input it expects.
  • Implement the Python functions that execute each tool.
  • Test each tool independently to confirm it works.
Create agent.py and set up the client

Before you define any tools, you need a file and an initialized Anthropic client. The SDK automatically reads your ANTHROPIC_API_KEY environment variable, so no manual key handling is needed.

  • Create a new file called agent.py inside your multi-tool-agent folder by running this command:
touch agent.py
  • Open agent.py in VS Code and add the imports and client initialization:
import os
import json
import math
import subprocess
import urllib.request
import urllib.parse

from anthropic import Anthropic

# Initialize the client
client = Anthropic()

What does this code do?

The first six imports are Python standard library modules you'll use in your tool implementations: json for formatting tool outputs, math for calculator functions, subprocess for running Python code, and urllib for calling Wikipedia.

The from anthropic import Anthropic line gives you the SDK client. Calling Anthropic() with no arguments automatically picks up the ANTHROPIC_API_KEY you exported earlier.

You won't see a change yet. The next chunk adds the tool definitions that Claude will read.

Define the tool schemas

Claude's tool use API needs a structured description of each tool before it can call them. Each tool definition has three parts: a name, a description that tells Claude when to use it, and an input_schema in JSON Schema format that defines the expected parameters.

  • Add the tool definitions below your client = Anthropic() line:
# --- Tool Definitions ---

tools = [
    {
        "name": "calculator",
        "description": "Evaluate a mathematical expression. Supports basic arithmetic (+, -, *, /), exponents (**), and common math functions (sqrt, sin, cos, tan, log, log10, pi, e, abs, round, pow). Use this for any calculation.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2', 'sqrt(16)', '3 ** 4'",
                }
            },
            "required": ["expression"],
        },
    },
    {
        "name": "wikipedia_search",
        "description": "Search Wikipedia for information about a topic. Returns a summary of the Wikipedia article. Use this when you need factual information about people, places, events, concepts, or things.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The topic to search for on Wikipedia, e.g. 'Albert Einstein', 'Python programming language'",
                }
            },
            "required": ["query"],
        },
    },
    {
        "name": "python_executor",
        "description": "Execute Python code and return the output. Use this for complex computations, data manipulation, list processing, or when you need to run code that goes beyond simple math. The code should print its results to stdout using print().",
        "input_schema": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "The Python code to execute. Must use print() to output results.",
                }
            },
            "required": ["code"],
        },
    },
]

What does this code do?

The tools list is what you'll pass to Claude's API later. Each dictionary describes one tool:

  • name is the identifier Claude uses when it wants to call the tool.
  • description tells Claude what the tool does and when to pick it. The better the description, the smarter Claude's tool selection.
  • input_schema uses JSON Schema to define what parameters Claude must provide. Claude generates valid JSON matching this schema automatically.

Why JSON Schema for tool definitions?

JSON Schema is a standard way to describe the shape of JSON data. Claude's API uses it so the model knows exactly what inputs each tool accepts.

When Claude decides to call a tool, it generates input that matches the schema. This means your handler functions always receive well-structured data.

  • Save agent.py.

The schemas tell Claude what tools exist. Next, you'll write the functions that actually do the work when Claude calls them.

Implement the tool functions

Each tool schema needs a matching Python function that executes the actual operation. These functions receive input from Claude and return JSON-formatted results.

  • Add the calculator implementation below the tools list:
# --- Tool Implementations ---


def execute_calculator(expression):
    """Safely evaluate a mathematical expression."""
    allowed_names = {
        "sqrt": math.sqrt,
        "sin": math.sin,
        "cos": math.cos,
        "tan": math.tan,
        "log": math.log,
        "log10": math.log10,
        "pi": math.pi,
        "e": math.e,
        "abs": abs,
        "round": round,
        "pow": pow,
    }
    try:
        result = eval(expression, {"__builtins__": {}}, allowed_names)
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": f"Calculator error: {str(e)}"})

What does this code do?

The execute_calculator function uses Python's eval() to evaluate math expressions. The allowed_names dictionary restricts what functions are available inside the evaluation.

Passing {"__builtins__": {}} as the globals blocks access to dangerous built-in functions like open() or import. Only the math operations listed in allowed_names can be called.

  • Add the Wikipedia search implementation below execute_calculator:
def execute_wikipedia_search(query):
    """Search Wikipedia and return a summary."""
    try:
        encoded_query = urllib.parse.quote(query)
        url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
        req = urllib.request.Request(url, headers={"User-Agent": "MultiToolAgent/1.0"})
        with urllib.request.urlopen(req, timeout=10) as response:
            data = json.loads(response.read().decode())
            return json.dumps(
                {
                    "title": data.get("title", "Unknown"),
                    "summary": data.get("extract", "No summary available."),
                    "url": data.get("content_urls", {}).get("desktop", {}).get(
                        "page", ""
                    ),
                }
            )
    except urllib.error.HTTPError as e:
        return json.dumps({"error": f"Wikipedia article not found: {str(e)}"})
    except Exception as e:
        return json.dumps({"error": f"Wikipedia search failed: {str(e)}"})

What does this code do?

This function calls Wikipedia's free REST API at https://en.wikipedia.org/api/rest_v1/page/summary/{title}. It requires no authentication. The only requirement is a User-Agent header.

The response includes the article title, a plain-text summary, and a URL. Error handling catches both HTTP errors (article not found) and network failures separately.

  • Add the Python code executor implementation below execute_wikipedia_search:
def execute_python(code):
    """Execute Python code in a subprocess and return output."""
    try:
        result = subprocess.run(
            ["python3", "-c", code],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            return json.dumps({"output": result.stdout.strip()})
        else:
            return json.dumps({"error": result.stderr.strip()})
    except subprocess.TimeoutExpired:
        return json.dumps({"error": "Code execution timed out (10s limit)"})
    except Exception as e:
        return json.dumps({"error": f"Execution failed: {str(e)}"})

What does this code do?

The execute_python function runs code in a separate process using subprocess.run(). This provides isolation from your agent process and a 10-second timeout to prevent runaway scripts.

If the code runs successfully (returncode == 0), the function returns stdout. If it fails, it returns stderr as an error message. The agent will read these results and decide what to do next.

  • Save agent.py.

Why subprocess instead of exec()?

Using subprocess.run() runs the code in a completely separate Python process. This gives you proper isolation, timeout support, and clean stdout/stderr separation.

If you used exec() directly, the generated code would share memory with your agent. A bad script could corrupt your agent's state or hang indefinitely with no timeout.

All three tool functions are in place. Time to verify they work before wiring them up to Claude.

Test each tool independently

Before connecting these tools to the agentic loop, you should confirm each one works on its own. A quick test now saves debugging time later.

  • Test the calculator by running this command:
python3 -c "from agent import execute_calculator; print(execute_calculator('sqrt(144) + 3 ** 2'))"

You should see {"result": 21.0} printed in your terminal.

  • Test the Wikipedia search by running:
python3 -c "from agent import execute_wikipedia_search; print(execute_wikipedia_search('Python (programming language)'))"

You should see a JSON object containing a title, summary, and URL for the Python Wikipedia article.

  • Test the Python executor by running:
python3 -c "from agent import execute_python; print(execute_python('print(sum(range(1, 101)))'))"

You should see {"output": "5050"} printed. This confirms your subprocess execution and timeout handling work correctly.

Getting a ModuleNotFoundError?

Make sure your virtual environment is still active. You should see (.venv) at the start of your terminal prompt. If not, reactivate it:

source .venv/bin/activate

Also confirm you're running the commands from inside the multi-tool-agent folder where agent.py lives.

help me fix my ModuleNotFoundError when testing tools

Wikipedia test returning an error?

The Wikipedia API requires an internet connection. Check that you can reach the internet from your terminal. Also note that Wikipedia article names are case-sensitive. The query must match a real article title.

help me debug my Wikipedia tool

✔️ Awesome, I've got everything!

Great. Double check you've saved agent.py with all the imports, the client, the tool definitions, and the three implementation functions.

ⓧ I'd like to double check the full code

Here is the complete agent.py so far:

import os
import json
import math
import subprocess
import urllib.request
import urllib.parse

from anthropic import Anthropic

# Initialize the client
client = Anthropic()

# --- Tool Definitions ---

tools = [
    {
        "name": "calculator",
        "description": "Evaluate a mathematical expression. Supports basic arithmetic (+, -, *, /), exponents (**), and common math functions (sqrt, sin, cos, tan, log, log10, pi, e, abs, round, pow). Use this for any calculation.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2', 'sqrt(16)', '3 ** 4'",
                }
            },
            "required": ["expression"],
        },
    },
    {
        "name": "wikipedia_search",
        "description": "Search Wikipedia for information about a topic. Returns a summary of the Wikipedia article. Use this when you need factual information about people, places, events, concepts, or things.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The topic to search for on Wikipedia, e.g. 'Albert Einstein', 'Python programming language'",
                }
            },
            "required": ["query"],
        },
    },
    {
        "name": "python_executor",
        "description": "Execute Python code and return the output. Use this for complex computations, data manipulation, list processing, or when you need to run code that goes beyond simple math. The code should print its results to stdout using print().",
        "input_schema": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "The Python code to execute. Must use print() to output results.",
                }
            },
            "required": ["code"],
        },
    },
]

# --- Tool Implementations ---


def execute_calculator(expression):
    """Safely evaluate a mathematical expression."""
    allowed_names = {
        "sqrt": math.sqrt,
        "sin": math.sin,
        "cos": math.cos,
        "tan": math.tan,
        "log": math.log,
        "log10": math.log10,
        "pi": math.pi,
        "e": math.e,
        "abs": abs,
        "round": round,
        "pow": pow,
    }
    try:
        result = eval(expression, {"__builtins__": {}}, allowed_names)
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": f"Calculator error: {str(e)}"})


def execute_wikipedia_search(query):
    """Search Wikipedia and return a summary."""
    try:
        encoded_query = urllib.parse.quote(query)
        url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
        req = urllib.request.Request(url, headers={"User-Agent": "MultiToolAgent/1.0"})
        with urllib.request.urlopen(req, timeout=10) as response:
            data = json.loads(response.read().decode())
            return json.dumps(
                {
                    "title": data.get("title", "Unknown"),
                    "summary": data.get("extract", "No summary available."),
                    "url": data.get("content_urls", {}).get("desktop", {}).get(
                        "page", ""
                    ),
                }
            )
    except urllib.error.HTTPError as e:
        return json.dumps({"error": f"Wikipedia article not found: {str(e)}"})
    except Exception as e:
        return json.dumps({"error": f"Wikipedia search failed: {str(e)}"})


def execute_python(code):
    """Execute Python code in a subprocess and return output."""
    try:
        result = subprocess.run(
            ["python3", "-c", code],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            return json.dumps({"output": result.stdout.strip()})
        else:
            return json.dumps({"error": result.stderr.strip()})
    except subprocess.TimeoutExpired:
        return json.dumps({"error": "Code execution timed out (10s limit)"})
    except Exception as e:
        return json.dumps({"error": f"Execution failed: {str(e)}"})

Your three tools are defined and tested. Next up, you'll build the agentic loop that lets Claude decide which tools to call and chain their results together to answer complex questions.

Build the Agentic Loop

You have three working tools, but no orchestration layer connecting them to Claude. Right now, if Claude wanted to use your calculator, nothing would route that request to the right function and feed the result back.

In this step, you will build the agentic loop. This is the core pattern that powers every production AI agent: send a message to Claude, check if it wants to use a tool, execute the tool, send the result back, and repeat until Claude has a final answer.

In this step, get ready to:
  • Implement a tool dispatcher that routes Claude's requests to the correct handler function.
  • Build the agentic loop with stop_reason routing and message history management.
  • Test the complete agent with a single-tool query.
Implement the tool dispatcher

When Claude decides to use a tool, it sends back a tool name and input parameters. Your agent needs a single entry point that takes that name and routes it to the correct Python function.

  • In agent.py, add the following code below your execute_python function to create the tool dispatcher:
# --- Tool Dispatcher ---


def dispatch_tool(tool_name, tool_input):
    """Route a tool call to the correct implementation."""
    handlers = {
        "calculator": lambda inp: execute_calculator(inp["expression"]),
        "wikipedia_search": lambda inp: execute_wikipedia_search(inp["query"]),
        "python_executor": lambda inp: execute_python(inp["code"]),
    }
    handler = handlers.get(tool_name)
    if handler:
        try:
            return handler(tool_input)
        except Exception as e:
            return json.dumps({"error": f"Tool '{tool_name}' failed: {str(e)}"})
    else:
        return json.dumps({"error": f"Unknown tool: {tool_name}"})

What does this code do?

The handlers dictionary maps each tool name to a lambda that extracts the right input key and calls the matching function. This is the dispatcher pattern.

If Claude requests an unknown tool name, the function returns a structured error JSON instead of crashing. The try/except also catches any runtime errors inside the tool functions themselves.

  • Save agent.py.

You won't see a change yet. The dispatcher gets used by the agentic loop you are building next.

Build the run_agent function

The agentic loop is a while-style loop that keeps calling Claude's API until Claude signals it is done. Each iteration, your code checks the stop_reason field on the response. If it says "end_turn", Claude has a final answer. If it says "tool_use", Claude wants to call one or more tools before finishing.

First, you need a system prompt that instructs Claude on how to behave when tools are available.

  • Below your dispatch_tool function, add the system prompt constant:
# --- Agentic Loop ---

SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. Follow these guidelines:

1. Use your tools to investigate and solve problems step by step.
2. When a question requires factual information, use wikipedia_search rather than relying on your training data.
3. When a question requires calculations, use the calculator or python_executor tool rather than calculating in your head.
4. If a question has multiple independent parts, call multiple tools in parallel to save time.
5. After gathering information from tools, synthesize a clear, complete answer for the user."""

What does this system prompt do?

The system prompt steers Claude's behavior when tools are available. Without explicit instructions, Claude might try to answer math questions from memory or skip tools entirely.

Rule 4 is especially important. It tells Claude to call multiple tools in parallel when a question has independent parts, which reduces the number of loop iterations needed.

Now for the main event. The following function is the complete agentic loop.

  • Directly below SYSTEM_PROMPT, add the run_agent function:
def run_agent(user_message, max_iterations=10):
    """Run the multi-tool agent loop until Claude completes the task."""
    print(f"\n{'='*60}")
    print(f"User: {user_message}")
    print(f"{'='*60}")

    messages = [{"role": "user", "content": user_message}]
    total_input_tokens = 0
    total_output_tokens = 0

    for iteration in range(max_iterations):
        print(f"\n--- Iteration {iteration + 1} ---")

        response = client.messages.create(
            model="claude-sonnet-4-5-20250929",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=tools,
            messages=messages,
        )

        # Track token usage
        total_input_tokens += response.usage.input_tokens
        total_output_tokens += response.usage.output_tokens

        print(f"  Stop reason: {response.stop_reason}")

        # Add assistant response to message history
        messages.append({"role": "assistant", "content": response.content})

        # If Claude is done, extract and return the final text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if block.type == "text":
                    final_text += block.text
            print(f"\nAgent: {final_text}")
            print(f"\n--- Token Usage ---")
            print(f"  Input tokens:  {total_input_tokens}")
            print(f"  Output tokens: {total_output_tokens}")
            print(f"  Estimated cost: ${(total_input_tokens * 3 + total_output_tokens * 15) / 1_000_000:.4f}")
            return final_text

        # Process tool calls
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                print(f"  Tool call: {block.name}({json.dumps(block.input, indent=None)[:100]})")
                result = dispatch_tool(block.name, block.input)
                print(f"  Result: {result[:150]}{'...' if len(result) > 150 else ''}")
                tool_results.append(
                    {
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    }
                )

        # Send tool results back to Claude
        if tool_results:
            messages.append({"role": "user", "content": tool_results})
        else:
            print("  No tool calls found but stop_reason was not end_turn. Breaking.")
            break

    print("\nAgent reached maximum iterations without completing.")
    return None

What does this function do?

This is the heart of the agent. Here is the flow:

  • It calls client.messages.create with the user message, tool definitions, and system prompt.
  • It checks response.stop_reason. If "end_turn", Claude is done and the function extracts the final text.
  • If the stop reason is "tool_use", it iterates response.content looking for blocks with block.type == "tool_use".
  • For each tool_use block, it calls dispatch_tool and packages the result into a tool_result dict with the matching tool_use_id.
  • It appends all tool results as a user message and loops back to Claude for the next iteration.
  • The max_iterations=10 safety limit prevents infinite loops if something goes wrong.

Why does tool_use_id matter?

Claude can request multiple tools in a single response. Each tool_use block has a unique block.id. When you send results back, the tool_use_id field tells Claude which result belongs to which request. Without this match, Claude cannot correlate answers to questions.

  • Save agent.py.

You won't see a change yet. The next chunk adds the entry point so you can run the agent interactively.

Test with a single-tool query

To run the agent interactively, you need a main block that reads user input in a loop and passes each message to run_agent.

  • At the very bottom of agent.py, add the main entry point:
# --- Main ---

if __name__ == "__main__":
    print("Multi-Tool AI Agent")
    print("Type 'quit' to exit.\n")

    while True:
        user_input = input("\nYou: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            print("Goodbye!")
            break
        if user_input:
            run_agent(user_input)

What does this code do?

This creates a simple REPL (read-eval-print loop). It continuously reads input from you, passes it to the agent, and prints results until you type quit.

  • Save agent.py.
  • Run the agent by executing this command in your terminal:
python3 agent.py
  • When you see the You: prompt, type this test query:
What is the square root of 256?

You should see output showing the full loop: an iteration log, a tool call to the calculator, the result, and then Claude's final natural language answer (16).

What should the output look like?

You will see something like this in your terminal:

  • Iteration 1 starts, with stop reason tool_use.
  • A tool call to calculator with an expression like sqrt(256) or 256 ** 0.5.
  • The result showing {"result": 16.0}.
  • Iteration 2 with stop reason end_turn, followed by Claude's synthesized answer.
  • A token usage summary at the end.
  • Type quit to exit the agent.

Seeing an error when you run the agent?

  • If you see AuthenticationError, your ANTHROPIC_API_KEY environment variable is not set or is incorrect. Run echo $ANTHROPIC_API_KEY to check it is present.
  • If you see NameError: name 'dispatch_tool' is not defined, make sure the dispatch_tool function is defined above run_agent in your file.
  • If the agent hangs without printing anything, check that your virtual environment is activated and that anthropic is installed inside it.

Help me debug my agent setup

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full agent.py as it should look at the end of this step:

import os
import json
import math
import subprocess
import urllib.request
import urllib.parse

from anthropic import Anthropic

# Initialize the client
client = Anthropic()

# --- Tool Definitions ---

tools = [
    {
        "name": "calculator",
        "description": "Evaluate a mathematical expression. Supports basic arithmetic (+, -, *, /), exponents (**), and common math functions (sqrt, sin, cos, tan, log, log10, pi, e, abs, round, pow). Use this for any calculation.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2', 'sqrt(16)', '3 ** 4'",
                }
            },
            "required": ["expression"],
        },
    },
    {
        "name": "wikipedia_search",
        "description": "Search Wikipedia for information about a topic. Returns a summary of the Wikipedia article. Use this when you need factual information about people, places, events, concepts, or things.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The topic to search for on Wikipedia, e.g. 'Albert Einstein', 'Python programming language'",
                }
            },
            "required": ["query"],
        },
    },
    {
        "name": "python_executor",
        "description": "Execute Python code and return the output. Use this for complex computations, data manipulation, list processing, or when you need to run code that goes beyond simple math. The code should print its results to stdout using print().",
        "input_schema": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "The Python code to execute. Must use print() to output results.",
                }
            },
            "required": ["code"],
        },
    },
]

# --- Tool Implementations ---


def execute_calculator(expression):
    """Safely evaluate a mathematical expression."""
    allowed_names = {
        "sqrt": math.sqrt,
        "sin": math.sin,
        "cos": math.cos,
        "tan": math.tan,
        "log": math.log,
        "log10": math.log10,
        "pi": math.pi,
        "e": math.e,
        "abs": abs,
        "round": round,
        "pow": pow,
    }
    try:
        result = eval(expression, {"__builtins__": {}}, allowed_names)
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": f"Calculator error: {str(e)}"})


def execute_wikipedia_search(query):
    """Search Wikipedia and return a summary."""
    try:
        encoded_query = urllib.parse.quote(query)
        url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
        req = urllib.request.Request(url, headers={"User-Agent": "MultiToolAgent/1.0"})
        with urllib.request.urlopen(req, timeout=10) as response:
            data = json.loads(response.read().decode())
            return json.dumps(
                {
                    "title": data.get("title", "Unknown"),
                    "summary": data.get("extract", "No summary available."),
                    "url": data.get("content_urls", {}).get("desktop", {}).get(
                        "page", ""
                    ),
                }
            )
    except urllib.error.HTTPError as e:
        return json.dumps({"error": f"Wikipedia article not found: {str(e)}"})
    except Exception as e:
        return json.dumps({"error": f"Wikipedia search failed: {str(e)}"})


def execute_python(code):
    """Execute Python code in a subprocess and return output."""
    try:
        result = subprocess.run(
            ["python3", "-c", code],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            return json.dumps({"output": result.stdout.strip()})
        else:
            return json.dumps({"error": result.stderr.strip()})
    except subprocess.TimeoutExpired:
        return json.dumps({"error": "Code execution timed out (10s limit)"})
    except Exception as e:
        return json.dumps({"error": f"Execution failed: {str(e)}"})


# --- Tool Dispatcher ---


def dispatch_tool(tool_name, tool_input):
    """Route a tool call to the correct implementation."""
    handlers = {
        "calculator": lambda inp: execute_calculator(inp["expression"]),
        "wikipedia_search": lambda inp: execute_wikipedia_search(inp["query"]),
        "python_executor": lambda inp: execute_python(inp["code"]),
    }
    handler = handlers.get(tool_name)
    if handler:
        try:
            return handler(tool_input)
        except Exception as e:
            return json.dumps({"error": f"Tool '{tool_name}' failed: {str(e)}"})
    else:
        return json.dumps({"error": f"Unknown tool: {tool_name}"})


# --- Agentic Loop ---

SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. Follow these guidelines:

1. Use your tools to investigate and solve problems step by step.
2. When a question requires factual information, use wikipedia_search rather than relying on your training data.
3. When a question requires calculations, use the calculator or python_executor tool rather than calculating in your head.
4. If a question has multiple independent parts, call multiple tools in parallel to save time.
5. After gathering information from tools, synthesize a clear, complete answer for the user."""


def run_agent(user_message, max_iterations=10):
    """Run the multi-tool agent loop until Claude completes the task."""
    print(f"\n{'='*60}")
    print(f"User: {user_message}")
    print(f"{'='*60}")

    messages = [{"role": "user", "content": user_message}]
    total_input_tokens = 0
    total_output_tokens = 0

    for iteration in range(max_iterations):
        print(f"\n--- Iteration {iteration + 1} ---")

        response = client.messages.create(
            model="claude-sonnet-4-5-20250929",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=tools,
            messages=messages,
        )

        # Track token usage
        total_input_tokens += response.usage.input_tokens
        total_output_tokens += response.usage.output_tokens

        print(f"  Stop reason: {response.stop_reason}")

        # Add assistant response to message history
        messages.append({"role": "assistant", "content": response.content})

        # If Claude is done, extract and return the final text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if block.type == "text":
                    final_text += block.text
            print(f"\nAgent: {final_text}")
            print(f"\n--- Token Usage ---")
            print(f"  Input tokens:  {total_input_tokens}")
            print(f"  Output tokens: {total_output_tokens}")
            print(f"  Estimated cost: ${(total_input_tokens * 3 + total_output_tokens * 15) / 1_000_000:.4f}")
            return final_text

        # Process tool calls
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                print(f"  Tool call: {block.name}({json.dumps(block.input, indent=None)[:100]})")
                result = dispatch_tool(block.name, block.input)
                print(f"  Result: {result[:150]}{'...' if len(result) > 150 else ''}")
                tool_results.append(
                    {
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    }
                )

        # Send tool results back to Claude
        if tool_results:
            messages.append({"role": "user", "content": tool_results})
        else:
            print("  No tool calls found but stop_reason was not end_turn. Breaking.")
            break

    print("\nAgent reached maximum iterations without completing.")
    return None


# --- Main ---

if __name__ == "__main__":
    print("Multi-Tool AI Agent")
    print("Type 'quit' to exit.\n")

    while True:
        user_input = input("\nYou: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            print("Goodbye!")
            break
        if user_input:
            run_agent(user_input)

Your agent works end-to-end with a single tool call. Next up, you will make it handle parallel calls and recover gracefully from errors.

Handle Parallel Calls and Errors

Your agent can already call tools one at a time and loop until Claude finishes. But real-world questions often have independent parts that could be answered simultaneously.

In this step, you will push your agent with complex queries that require multiple tools at once. You will also verify that errors in one tool do not crash the entire agent.

In this step, get ready to:
  • Understand how your code handles parallel tool calls and errors.
  • Test the agent with a multi-step query that triggers multiple tools.
  • Verify that bad input triggers graceful error recovery instead of crashing.
Understand how parallel calls and error handling work

When you ask a question with multiple independent parts, Claude can request several tools at once in a single response. Your code already handles this pattern.

What are parallel tool calls?

Claude can return multiple tool_use blocks in a single response. For example, if you ask about the population of France AND a calculation, Claude may request both wikipedia_search and calculator in one go.

Your for block in response.content loop iterates ALL blocks and collects ALL tool_result entries before sending them back together in one user message. This is what makes parallel execution work.

  • In agent.py, find the # Process tool calls section inside run_agent().
  • Notice that tool_results is a list that accumulates every result before the loop sends them all back at once.

How does error handling work?

Your dispatch_tool() function wraps each handler call in a try/except block. If a tool raises an exception, the dispatcher catches it and returns structured error JSON with an "error" key instead of crashing the entire agent.

Claude then receives the error as a tool_result and can decide how to proceed. It might retry with different input, use a different tool, or explain the failure to the user.

  • Find the SYSTEM_PROMPT constant near the top of the # --- Agentic Loop --- section.
  • Notice guideline 4: "If a question has multiple independent parts, call multiple tools in parallel to save time." This steers Claude toward making parallel requests when appropriate.

Why does the system prompt matter for tool use?

Without explicit instructions, Claude might answer factual questions from its training data instead of calling wikipedia_search. It might also do mental math instead of using the calculator tool.

The system prompt ensures Claude relies on your tools for verifiable facts and calculations. This is the same pattern production agents use to keep their outputs grounded in real data.

Test with a complex multi-tool query

Time to see parallel tool calls in action. You will give the agent a question that requires both a Wikipedia lookup and a calculation.

  • Run the agent by executing this command in your terminal:
python3 agent.py
  • At the You: prompt, type this query and press Enter:
What is the population of France, and what would be the population if it grew by 2.5% per year for the next 10 years?
  • Watch the console output. You should see the agent call wikipedia_search for France's population, then use calculator or python_executor to compute the growth.

What should I see?

The console prints each iteration with the tool calls Claude makes. You may see Claude call multiple tools in the same iteration (printed as separate Tool call: lines under the same iteration header). Alternatively, Claude may chain tools across multiple iterations.

Either approach is valid. The key signal is that the final answer incorporates data from Wikipedia AND a calculation result. You will also see token usage and estimated cost at the end.

Agent not calling tools?

If Claude answers from memory without using tools, check that SYSTEM_PROMPT is being passed to the client.messages.create() call as the system parameter. Also verify that tools=tools is included in the same call.

Help me debug why my agent isn't calling tools

Test error handling with bad input

A production agent must handle tool failures without crashing. Your dispatcher catches exceptions and returns error JSON, which Claude can then reason about.

  • In the running agent, type this query designed to trigger a calculator error:
What is 1 divided by 0?
  • Watch the console output. The calculator tool should return an error result, and Claude should explain the mathematical impossibility instead of crashing.

What happens when a tool fails?

The execute_calculator() function catches the ZeroDivisionError and returns {"error": "Calculator error: division by zero"}. Claude receives this as a normal tool_result and uses it to formulate a helpful response.

  • Try one more query that combines a potential failure with a successful tool call:
What is the square root of -1, and who invented imaginary numbers?
  • The calculator may return an error for sqrt(-1) while the Wikipedia search succeeds. Claude should synthesize both results gracefully.
  • Type quit to exit the agent when you are done testing.

Agent crashing instead of handling errors?

If the agent crashes with an unhandled exception, check that your dispatch_tool() function has a try/except wrapping the handler(tool_input) call. Also verify each tool implementation (like execute_calculator) has its own try/except that returns error JSON rather than raising.

Help me debug why my agent crashes on bad tool input

✔️ Awesome, I've got everything!

Great. Make sure you have saved agent.py and that all your tests ran successfully.

ⓧ I'd like to double check the full code

Here is the complete agent.py file. Compare it against yours to spot any differences.

import os
import json
import math
import subprocess
import urllib.request
import urllib.parse

from anthropic import Anthropic

# Initialize the client
client = Anthropic()

# --- Tool Definitions ---

tools = [
    {
        "name": "calculator",
        "description": "Evaluate a mathematical expression. Supports basic arithmetic (+, -, *, /), exponents (**), and common math functions (sqrt, sin, cos, tan, log, log10, pi, e, abs, round, pow). Use this for any calculation.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The mathematical expression to evaluate, e.g. '2 + 2', 'sqrt(16)', '3 ** 4'",
                }
            },
            "required": ["expression"],
        },
    },
    {
        "name": "wikipedia_search",
        "description": "Search Wikipedia for information about a topic. Returns a summary of the Wikipedia article. Use this when you need factual information about people, places, events, concepts, or things.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The topic to search for on Wikipedia, e.g. 'Albert Einstein', 'Python programming language'",
                }
            },
            "required": ["query"],
        },
    },
    {
        "name": "python_executor",
        "description": "Execute Python code and return the output. Use this for complex computations, data manipulation, list processing, or when you need to run code that goes beyond simple math. The code should print its results to stdout using print().",
        "input_schema": {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "The Python code to execute. Must use print() to output results.",
                }
            },
            "required": ["code"],
        },
    },
]

# --- Tool Implementations ---


def execute_calculator(expression):
    """Safely evaluate a mathematical expression."""
    allowed_names = {
        "sqrt": math.sqrt,
        "sin": math.sin,
        "cos": math.cos,
        "tan": math.tan,
        "log": math.log,
        "log10": math.log10,
        "pi": math.pi,
        "e": math.e,
        "abs": abs,
        "round": round,
        "pow": pow,
    }
    try:
        result = eval(expression, {"__builtins__": {}}, allowed_names)
        return json.dumps({"result": result})
    except Exception as e:
        return json.dumps({"error": f"Calculator error: {str(e)}"})


def execute_wikipedia_search(query):
    """Search Wikipedia and return a summary."""
    try:
        encoded_query = urllib.parse.quote(query)
        url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_query}"
        req = urllib.request.Request(url, headers={"User-Agent": "MultiToolAgent/1.0"})
        with urllib.request.urlopen(req, timeout=10) as response:
            data = json.loads(response.read().decode())
            return json.dumps(
                {
                    "title": data.get("title", "Unknown"),
                    "summary": data.get("extract", "No summary available."),
                    "url": data.get("content_urls", {}).get("desktop", {}).get(
                        "page", ""
                    ),
                }
            )
    except urllib.error.HTTPError as e:
        return json.dumps({"error": f"Wikipedia article not found: {str(e)}"})
    except Exception as e:
        return json.dumps({"error": f"Wikipedia search failed: {str(e)}"})


def execute_python(code):
    """Execute Python code in a subprocess and return output."""
    try:
        result = subprocess.run(
            ["python3", "-c", code],
            capture_output=True,
            text=True,
            timeout=10,
        )
        if result.returncode == 0:
            return json.dumps({"output": result.stdout.strip()})
        else:
            return json.dumps({"error": result.stderr.strip()})
    except subprocess.TimeoutExpired:
        return json.dumps({"error": "Code execution timed out (10s limit)"})
    except Exception as e:
        return json.dumps({"error": f"Execution failed: {str(e)}"})


# --- Tool Dispatcher ---


def dispatch_tool(tool_name, tool_input):
    """Route a tool call to the correct implementation."""
    handlers = {
        "calculator": lambda inp: execute_calculator(inp["expression"]),
        "wikipedia_search": lambda inp: execute_wikipedia_search(inp["query"]),
        "python_executor": lambda inp: execute_python(inp["code"]),
    }
    handler = handlers.get(tool_name)
    if handler:
        try:
            return handler(tool_input)
        except Exception as e:
            return json.dumps({"error": f"Tool '{tool_name}' failed: {str(e)}"})
    else:
        return json.dumps({"error": f"Unknown tool: {tool_name}"})


# --- Agentic Loop ---

SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools. Follow these guidelines:

1. Use your tools to investigate and solve problems step by step.
2. When a question requires factual information, use wikipedia_search rather than relying on your training data.
3. When a question requires calculations, use the calculator or python_executor tool rather than calculating in your head.
4. If a question has multiple independent parts, call multiple tools in parallel to save time.
5. After gathering information from tools, synthesize a clear, complete answer for the user."""


def run_agent(user_message, max_iterations=10):
    """Run the multi-tool agent loop until Claude completes the task."""
    print(f"\n{'='*60}")
    print(f"User: {user_message}")
    print(f"{'='*60}")

    messages = [{"role": "user", "content": user_message}]
    total_input_tokens = 0
    total_output_tokens = 0

    for iteration in range(max_iterations):
        print(f"\n--- Iteration {iteration + 1} ---")

        response = client.messages.create(
            model="claude-sonnet-4-5-20250929",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=tools,
            messages=messages,
        )

        # Track token usage
        total_input_tokens += response.usage.input_tokens
        total_output_tokens += response.usage.output_tokens

        print(f"  Stop reason: {response.stop_reason}")

        # Add assistant response to message history
        messages.append({"role": "assistant", "content": response.content})

        # If Claude is done, extract and return the final text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if block.type == "text":
                    final_text += block.text
            print(f"\nAgent: {final_text}")
            print(f"\n--- Token Usage ---")
            print(f"  Input tokens:  {total_input_tokens}")
            print(f"  Output tokens: {total_output_tokens}")
            print(f"  Estimated cost: ${(total_input_tokens * 3 + total_output_tokens * 15) / 1_000_000:.4f}")
            return final_text

        # Process tool calls
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                print(f"  Tool call: {block.name}({json.dumps(block.input, indent=None)[:100]})")
                result = dispatch_tool(block.name, block.input)
                print(f"  Result: {result[:150]}{'...' if len(result) > 150 else ''}")
                tool_results.append(
                    {
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": result,
                    }
                )

        # Send tool results back to Claude
        if tool_results:
            messages.append({"role": "user", "content": tool_results})
        else:
            print("  No tool calls found but stop_reason was not end_turn. Breaking.")
            break

    print("\nAgent reached maximum iterations without completing.")
    return None


# --- Main ---

if __name__ == "__main__":
    print("Multi-Tool AI Agent")
    print("Type 'quit' to exit.\n")

    while True:
        user_input = input("\nYou: ").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            print("Goodbye!")
            break
        if user_input:
            run_agent(user_input)

Your agent now handles the two hardest parts of production tool use: parallel execution and graceful error recovery. Next up, you will push it with progressively harder challenges that chain multiple tools together across several iterations.

Run Multi-Step Challenges

Your agent can now handle parallel tool calls, recover from errors, and follow your system prompt's instructions. It is a fully functional multi-tool AI agent.

Time to push it to its limits. In this step, you will throw progressively harder queries at your agent that require it to chain multiple tools together. You will watch the verbose logging to see exactly how it decides which tools to call, and confirm that total costs stay well under budget.

In this step, get ready to:
  • Test the agent with three multi-step queries that require different tool combinations.
  • Read the verbose iteration logs to understand the agent's decision-making process.
  • Confirm that token usage and estimated cost stay under $0.50 per query.
Run challenge queries that chain multiple tools

Each of these queries is designed to require two or more tools working together. The agent must fetch information, then calculate with it, or generate code and then interpret the results.

  • Run your agent by executing this command in your terminal:
python3 agent.py

You should see the startup message and the You: input prompt.

  • Type the following query at the You: prompt and press Enter:
Compare the areas of France and Germany, then calculate what percentage France's area is of Germany's

What should I see?

Watch the iteration logs scroll by. The agent should call wikipedia_search for France and Germany (possibly in parallel), then use the calculator to divide and compute the percentage.

Each iteration prints the stop reason, tool name, truncated input, and truncated result. The final answer appears after all iterations complete.

  • Once the agent finishes responding, type this second query:
Write Python code to generate the first 20 Fibonacci numbers, then tell me what the 15th Fibonacci number divided by the 14th one is

What tool combination does this trigger?

This query should trigger python_executor to generate the Fibonacci sequence, then calculator to divide the 15th by the 14th number. The result will be close to the golden ratio (approximately 1.618).

  • Type the third and final challenge query:
What year was the Eiffel Tower built, and how many days have passed since then?

What should I see?

The agent should use wikipedia_search to look up the Eiffel Tower, then call python_executor to calculate the number of days between the construction date and today. This demonstrates the agent combining factual retrieval with code-based computation.

Agent not calling the right tools?

If the agent answers from memory instead of calling tools, check that your SYSTEM_PROMPT includes the instruction to use wikipedia_search for factual information rather than relying on training data.

If you see a network error from Wikipedia, check your internet connection. The Wikipedia REST API requires no authentication but does need connectivity.

Help me debug why my agent isn't using its tools

Read the verbose output and token cost

After each query completes, your agent prints a --- Token Usage --- section showing exactly how many tokens each run consumed.

  • Look at the token usage output after your third query.
  • Confirm that each query uses between 2 and 4 tool calls (visible in the iteration logs).
  • Verify that the estimated cost per query stays well under $0.50.

How is the cost calculated?

Your agent calculates cost using the claude-sonnet-4-5-20250929 pricing: $3 per million input tokens and $15 per million output tokens. A typical multi-tool query with 3-4 iterations costs a fraction of a cent.

  • Type quit to exit the agent when you are done testing.

Why does each iteration get more expensive?

Each loop iteration sends the entire conversation history (including all previous tool results) back to Claude. This means input tokens grow with each iteration. The max_iterations=10 safety limit prevents runaway costs if something goes wrong.

Cost seems higher than expected?

If a single query reports more than $0.10, the agent may be looping too many times. Check the iteration count in the logs. If you see 7+ iterations for a simple query, your system prompt may need clearer instructions about when to stop and synthesize an answer.

Help me understand why my agent is using too many iterations

Your agent is complete. It autonomously chains tools, handles parallel calls, recovers from errors, and reports its own costs. You have built the exact same agentic loop pattern that powers production AI agents like Claude Code, Cursor, and Devin. Next up is the Secret Mission, where you will give your agent persistent memory.

Secret mission

Add File Memory and a Fourth Tool

Your agent is powerful, but it has amnesia. Every time you restart it, all context is lost. In this secret mission, you'll give your agent persistent memory by adding a file read/write tool and automatic conversation logging, so it can recall information across sessions.

Clean Up Your Resources

Clean Up Your Resources

This project runs locally and only charges for API calls you actively make. There are no background processes or idle costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • multi-tool-agent project folder on your Desktop (includes agent.py, .venv, and agent_memory/).
  • Anthropic API key (on console.anthropic.com, under Settings then API Keys).
  • ANTHROPIC_API_KEY environment variable (session-only, resets when you close the terminal).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your agent or adding new tools.

  • Your project folder stays on your Desktop.
  • Your API key remains active on the Anthropic Console. You only pay when you run the agent.
  • The agent_memory/ folder retains any saved conversations and files from your agent sessions.

Pause - I'll come back to this later

Free up terminal resources and deactivate the virtual environment while keeping all files intact.

  • Stop the running agent by pressing Ctrl+C in your terminal (if it is still running).
  • Deactivate the virtual environment by running:
deactivate
  • Your project folder, API key, and memory files all remain in place for next time.
  • To resume later, navigate back to ~/Desktop/multi-tool-agent, reactivate with source .venv/bin/activate, and run python3 agent.py.

Delete - I don't want to use this again

Remove all project resources and revoke your API key so nothing remains.

  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/multi-tool-agent
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\multi-tool-agent
  • Revoke your API key so it can no longer be used for requests. Open console.anthropic.com in your browser.
  • Navigate to Settings, then API Keys.
  • Find the key you created for this project and delete it.
  • If you added the ANTHROPIC_API_KEY export to a shell profile file (like .zshrc or .bashrc), remove that line. If you only ran export in the terminal, it disappears when you close the session.

Nice Work!

Nice Work!

You just built a fully functional multi-tool AI agent from scratch using Claude's tool use API. No frameworks, no shortcuts, no magic.

You now understand the exact orchestration pattern that powers production AI agents like Claude Code, Cursor, and Devin.

You've learned how to:

  • Build a multi-tool AI agent from scratch using Claude's tool use API, implementing tool definitions with JSON Schema and a clean tool dispatcher.
  • Implement the complete agentic loop pattern including stop_reason routing, tool_use/tool_result message flow, and iteration safety limits.
  • Handle parallel tool calls, structured error recovery, and system prompt engineering to steer Claude's tool-selection behavior.
  • Secret Mission: Added a file_operations tool and persistent conversation memory so the agent can save and recall information across sessions.

Ready to quiz yourself?