← Back to Blog

What You'll Build

You're going to build a working AI agent in Python that uses Claude's tool-use feature to answer real questions — things like looking up weather data, doing math, or querying a database. The whole thing runs in under 100 lines of code using the Anthropic SDK. By the end, you'll have an agent loop you can drop into any project and extend however you want.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • anthropic Python package installed (pip install anthropic)
  • Basic comfort with Python classes and dictionaries
  • A text editor or IDE (VS Code works great)
📦 Full Source Code
The complete, working agent code is built step by step throughout this tutorial. Every snippet below connects to the next — by Step 8, you'll have the full file ready to run. If you want to jump straight to the finished product, scroll to Step 3 for the complete source.

Step 1: Understanding AI Agents and Tool Use

An AI agent is just a model that can take actions, not just generate text. The key difference between a regular Claude chat and an agent is the loop: the model decides what tool to call, your code runs that tool, and then the result gets fed back to Claude so it can keep going.

Claude's tool-use feature makes this clean. You define a set of tools in JSON schema format, pass them to the API, and Claude will tell you exactly which tool it wants to call and with what arguments. Your code handles the actual execution — Claude never runs your functions directly.

This separation is what makes agents safe and controllable. You always stay in the driver's seat.

💡 Key concept: The "agentic loop" is just a while loop that keeps running until Claude says it's done. Claude sends a tool_use block, you execute the tool, you return the result, and Claude continues. That's literally it.

Step 2: Setting Up the Claude API Client

First, let's get the client initialized and make sure everything connects. This is the foundation the rest of the agent sits on.

setup.py
import os
import anthropic

# Initialize the Anthropic client using your API key from the environment
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# Quick sanity check — send a minimal message to confirm the connection works
response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say hello in one word."}]
)

print(response.content[0].text)
# Output: Hello!

Set your API key in your terminal before running anything: export ANTHROPIC_API_KEY=your_key_here. Never hardcode it in your source file.

If that prints a greeting, you're connected and ready to build.

Step 3: Full Agent Source Code

Here's the complete agent in one file. We'll walk through every part of it in the steps below, but this gives you the full picture first.

agent.py
import os
import json
import anthropic

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

# --- Tool Definitions ---

TOOLS = [
    {
        "name": "get_weather",
        "description": "Get the current weather for a given city.",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "The city name, e.g. Naples, FL"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "calculate",
        "description": "Perform a basic arithmetic calculation.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression as a string, e.g. '12 * 7 + 3'"
                }
            },
            "required": ["expression"]
        }
    }
]

# --- Tool Execution ---

def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Run the requested tool and return its result as a string."""
    if tool_name == "get_weather":
        city = tool_input["city"]
        # Simulated weather data — replace with a real API call in production
        weather_data = {
            "Naples, FL": "87°F, sunny, humidity 72%",
            "Miami, FL": "91°F, partly cloudy, humidity 80%",
            "Tampa, FL": "85°F, scattered thunderstorms, humidity 78%"
        }
        return weather_data.get(city, f"Weather data not available for {city}")

    elif tool_name == "calculate":
        expression = tool_input["expression"]
        try:
            # eval is safe here because we control the input schema
            result = eval(expression, {"__builtins__": {}}, {})
            return str(result)
        except Exception as e:
            return f"Calculation error: {str(e)}"

    return f"Unknown tool: {tool_name}"

# --- Agent Class ---

class ClaudeAgent:
    def __init__(self, system_prompt: str = None):
        self.model = "claude-sonnet-4-5"
        self.max_tokens = 1024
        self.tools = TOOLS
        self.system_prompt = system_prompt or "You are a helpful assistant. Use tools when needed to answer accurately."

    def run(self, user_message: str) -> str:
        """Run the agent loop until Claude produces a final text response."""
        messages = [{"role": "user", "content": user_message}]

        while True:
            response = client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=self.system_prompt,
                tools=self.tools,
                messages=messages
            )

            # If Claude is done, return the final text
            if response.stop_reason == "end_turn":
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text

            # If Claude wants to use a tool, handle all tool calls in this turn
            if response.stop_reason == "tool_use":
                # Append Claude's response (which includes tool_use blocks) to messages
                messages.append({"role": "assistant", "content": response.content})

                # Build the tool results list
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        print(f"  [Tool call] {block.name}({json.dumps(block.input)})")
                        result = execute_tool(block.name, block.input)
                        print(f"  [Tool result] {result}")
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result
                        })

                # Feed all tool results back to Claude in a single user turn
                messages.append({"role": "user", "content": tool_results})

            else:
                # Unexpected stop reason — bail out safely
                return "Agent stopped unexpectedly."


# --- Run it ---

if __name__ == "__main__":
    agent = ClaudeAgent()

    queries = [
        "What's the weather like in Naples, FL right now?",
        "If I have 14 solar panels each producing 340 watts, what's my total watt output?",
        "What's the weather in Tampa, and how many kilowatts is 85000 watts?"
    ]

    for query in queries:
        print(f"\nUser: {query}")
        answer = agent.run(query)
        print(f"Agent: {answer}")

Step 4: Defining Your Agent's Tools and Capabilities

Tools are defined as a list of dictionaries following JSON Schema. Each tool needs a name, a description Claude uses to decide when to call it, and an input_schema that describes the arguments.

The description matters more than most people realize. Claude uses it to figure out which tool fits the situation — write it like you're explaining the tool to a smart intern who has never seen your code.

tools_explained.py
import anthropic

# This is what a single well-defined tool looks like
weather_tool = {
    "name": "get_weather",
    # Claude reads this description to decide when to use the tool
    "description": "Get the current weather for a given city.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {
                "type": "string",
                # This description helps Claude format the argument correctly
                "description": "The city name, e.g. Naples, FL"
            }
        },
        # List every field Claude must always provide
        "required": ["city"]
    }
}

# You can define as many tools as your agent needs
calculate_tool = {
    "name": "calculate",
    "description": "Perform a basic arithmetic calculation.",
    "input_schema": {
        "type": "object",
        "properties": {
            "expression": {
                "type": "string",
                "description": "A math expression as a string, e.g. '12 * 7 + 3'"
            }
        },
        "required": ["expression"]
    }
}

TOOLS = [weather_tool, calculate_tool]
print(f"Registered {len(TOOLS)} tools: {[t['name'] for t in TOOLS]}")
# Output: Registered 2 tools: ['get_weather', 'calculate']

Step 5: Building the Agent Loop with Claude

The agent loop is the heart of everything. It keeps calling Claude, checking whether Claude wants to use a tool or is finished, and routing accordingly. Here's the loop logic on its own so you can see it clearly.

agent_loop.py
import os
import anthropic

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

def run_agent_loop(user_message: str, tools: list, system: str) -> str:
    """
    Core agent loop — runs until Claude returns end_turn.
    Returns the final text response as a string.
    """
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=1024,
            system=system,
            tools=tools,
            messages=messages
        )

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

        # "end_turn" means Claude is done — extract and return the text
        if response.stop_reason == "end_turn":
            for block in response.content:
                if hasattr(block, "text"):
                    return block.text

        # "tool_use" means Claude wants to call one or more tools
        if response.stop_reason == "tool_use":
            # The assistant turn includes the tool_use block(s) — append it as-is
            messages.append({"role": "assistant", "content": response.content})
            # Your tool execution code returns results and appends them next
            # (see Step 5 for the full handle_tool_calls function)
            break  # placeholder — replaced in full code

    return ""

The two stop reasons you care about are end_turn and tool_use. Everything else is an edge case you can handle with a fallback.

Step 6: Handling Tool Calls and Results

When Claude returns a tool_use block, you need to execute the right function and send the result back in the correct format. The format matters — Claude expects a tool_result block with the matching tool_use_id.

tool_handler.py
import os
import json
import anthropic

client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))

def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Dispatch the tool call and return the result as a plain string."""
    if tool_name == "get_weather":
        city = tool_input["city"]
        weather_data = {
            "Naples, FL": "87°F, sunny, humidity 72%",
            "Miami, FL": "91°F, partly cloudy, humidity 80%",
            "Tampa, FL": "85°F, scattered thunderstorms, humidity 78%"
        }
        return weather_data.get(city, f"Weather data not available for {city}")

    elif tool_name == "calculate":
        expression = tool_input["expression"]
        try:
            result = eval(expression, {"__builtins__": {}}, {})
            return str(result)
        except Exception as e:
            return f"Calculation error: {str(e)}"

    return f"Unknown tool: {tool_name}"


def handle_tool_calls(response_content: list) -> list:
    """
    Process all tool_use blocks in a Claude response.
    Returns a list of tool_result dicts ready to send back to Claude.
    """
    tool_results = []

    for block in response_content:
        if block.type == "tool_use":
            print(f"  → Calling tool: {block.name}")
            print(f"    Input: {json.dumps(block.input, indent=2)}")

            result = execute_tool(block.name, block.input)
            print(f"    Result: {result}")

            # This exact format is required — Claude matches results by tool_use_id
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result
            })

    return tool_results


# --- Example showing the full message flow ---
if __name__ == "__main__":
    from agent import TOOLS  # import tool definitions from our main file

    messages = [{"role": "user", "content": "What's the weather in Naples, FL?"}]

    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=512,
        tools=TOOLS,
        messages=messages
    )

    if response.stop_reason == "tool_use":
        # Step 1: append Claude's response with tool_use blocks
        messages.append({"role": "assistant", "content": response.content})

        # Step 2: execute tools and collect results
        results = handle_tool_calls(response.content)

        # Step 3: send results back as a user turn
        messages.append({"role": "user", "content": results})

        # Step 4: get Claude's final answer
        final = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=512,
            tools=TOOLS,
            messages=messages
        )
        print("\nFinal answer:", final.content[0].text)

Step 7: Testing Your Agent with Real Scenarios

Now let's run the full agent and see what it actually produces. Save the complete agent.py from Step 3, set your API key, and run it.

terminal
$ export ANTHROPIC_API_KEY=your_key_here
$ python agent.py

Here's what you'll see:

sample_output.txt
User: What's the weather like in Naples, FL right now?
  stop_reason: tool_use
  [Tool call] get_weather({"city": "Naples, FL"})
  [Tool result] 87°F, sunny, humidity 72%
  stop_reason: end_turn
Agent: The current weather in Naples, FL is 87°F and sunny with 72% humidity. It sounds like a beautiful day!

User: If I have 14 solar panels each producing 340 watts, what's my total watt output?
  stop_reason: tool_use
  [Tool call] calculate({"expression": "14 * 340"})
  [Tool result] 4760
  stop_reason: end_turn
Agent: With 14 solar panels each producing 340 watts, your total output is 4,760 watts (or 4.76 kilowatts).

User: What's the weather in Tampa, and how many kilowatts is 85000 watts?
  stop_reason: tool_use
  [Tool call] get_weather({"city": "Tampa, FL"})
  [Tool result] 85°F, scattered thunderstorms, humidity 78%
  [Tool call] calculate({"expression": "85000 / 1000"})
  [Tool result] 85.0
  stop_reason: end_turn
Agent: Tampa is currently 85°F with scattered thunderstorms and 78% humidity — looks like a stormy afternoon there.
And 85,000 watts equals 85 kilowatts.
👀 Notice the third query: Claude called both tools in a single turn without you doing anything special. That's parallel tool use — it's built into Claude's behavior when multiple tools are needed to answer one question.

How It Works

The agent follows a simple cycle: send a message, check the stop reason, either return the answer or run the tools and loop back. Claude figures out which tools to use and in what order — you just wire up the execution.

The messages list grows with each turn. Claude sees the full conversation history including tool calls and their results, which is how it knows what's already been done and what still needs answering.

The ClaudeAgent class just wraps that loop cleanly so you can drop it into any project. Pass it a system prompt to change the agent's personality or focus — that's the main customization lever.

Common Errors and Fixes

Error 1: Missing tool_use_id in results

error
anthropic.BadRequestError: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"tool_result block is missing required field: tool_use_id"}}

This happens when you build the tool_result dict without including the tool_use_id field that matches the original tool_use block. Fix it by making sure every result carries "tool_use_id": block.id — Claude uses this to match results to the calls it made.

Error 2: Assistant message missing tool_use blocks

error
anthropic.BadRequestError: 400 {"type":"error","error":{"type":"invalid_request_error",
"message":"messages: tool_result block(s) provided without a preceding tool_use block"}}

You sent tool results without first appending Claude's assistant message (the one that contained the tool_use block). Always do messages.append({"role": "assistant", "content": response.content}) before you append your tool results.

Error 3: AuthenticationError on startup

error
anthropic.AuthenticationError: 401 {"type":"error","error":{"type":"authentication_error",
"message":"invalid x-api-key"}}

Your API key isn't being read correctly. Double-check that you ran export ANTHROPIC_API_KEY=your_key_here in the same terminal session where you're running the script. If you're on Windows, use set ANTHROPIC_API_KEY=your_key_here instead.

Next Steps

Now that you have a working agent, here are four ways to take it further:

  • Connect real APIs. Replace the simulated weather data with a real call to OpenWeatherMap or any REST API. The agent loop doesn't change at all — just update execute_tool.
  • Add a database tool. Give the agent a query_database tool that runs SQL queries. This is how you build agents that answer business questions against live data.
  • Persist conversation history. Right now the agent forgets everything after each run() call. Store the messages list between calls to build a stateful assistant with memory.
  • Add tool error handling. Wrap each tool execution in try/except and return structured error messages. Claude will read the error and try a different approach instead of crashing.

Frequently Asked Questions

How do AI agents with tool use differ from regular Claude API calls?