← Back to Blog

What You'll Build

If you've been searching for a how to build AI agents tutorial that actually runs without breaking, this is it. By the end of this guide, you'll have a working AI agent in Python that uses Claude's tool-use API to answer questions, call functions, and loop autonomously until a task is complete. The whole thing fits in under 50 lines of core logic — no frameworks, no magic.

📦 Full Source Code
The complete, working agent code is built step-by-step in the sections below. Each snippet connects to the next. By Step 5, you'll have the entire file ready to copy and run. No missing pieces.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (grab one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic Python SDK installed (pip install anthropic)
  • A terminal and a text editor — that's it

Step 1: Set Up Your Claude API Environment

First, install the Anthropic SDK if you haven't already. One command and you're done.

terminal
pip install anthropic

Next, set your API key as an environment variable. Never hardcode it — that's how keys get leaked on GitHub.

terminal (Mac/Linux)
export ANTHROPIC_API_KEY="sk-ant-your-key-here"
terminal (Windows PowerShell)
$env:ANTHROPIC_API_KEY="sk-ant-your-key-here"

Now let's verify the SDK is working before we build anything. Run this quick test to confirm your key is valid.

test_connection.py
import anthropic
import os

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

message = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say hello in one sentence."}]
)

print(message.content[0].text)

You should see something like: "Hello! It's great to connect with you today." If you get an authentication error, double-check that the environment variable is set in the same terminal session.

Step 2: Create Your First Agent Class

Here's where we wire up the agent's core structure. We're using a class to keep state — specifically the conversation history — across multiple turns. That's the key difference between a one-shot API call and an actual agent.

agent.py
import anthropic
import os
import json

class ClaudeAgent:
    def __init__(self, system_prompt: str, tools: list):
        # Initialize the Anthropic client using the env variable
        self.client = anthropic.Anthropic(
            api_key=os.environ.get("ANTHROPIC_API_KEY")
        )
        self.model = "claude-sonnet-4-6"
        self.system_prompt = system_prompt
        self.tools = tools
        self.messages = []  # Conversation history persists across turns
        self.max_tokens = 4096

    def add_user_message(self, content: str):
        self.messages.append({"role": "user", "content": content})

    def add_assistant_message(self, content):
        self.messages.append({"role": "assistant", "content": content})

The messages list is what gives the agent memory. Every turn — user input, Claude's response, tool results — gets appended here so Claude always has context for the next step.

💡 Why store messages as a list?
Claude's API is stateless — it doesn't remember past calls. By maintaining self.messages in your class, you're replaying the full conversation on every API call. This is exactly how every production agent framework works under the hood.

Step 3: Define Tools for Your Agent

Tools are how Claude takes actions in the real world. You define them as JSON schemas, and Claude decides when and how to call them based on the task at hand. Think of them as the agent's hands.

For this tutorial, we'll give the agent three tools: a calculator, a weather lookup (simulated), and a text word counter. These cover the tool-use pattern cleanly without needing external API keys.

agent.py (continued — add below the class definition)
# Tool schemas — Claude reads these to know what's available
TOOLS = [
    {
        "name": "calculate",
        "description": "Perform basic arithmetic calculations. Use this for any math operations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression to evaluate, e.g. '15 * 24 + 7'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "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": "count_words",
        "description": "Count the number of words in a given text string.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The text to count words in"
                }
            },
            "required": ["text"]
        }
    }
]


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Route tool calls to their actual implementations."""

    if tool_name == "calculate":
        try:
            # Safely evaluate basic arithmetic only
            allowed_chars = set("0123456789+-*/()., ")
            expression = tool_input["expression"]
            if all(c in allowed_chars for c in expression):
                result = eval(expression)
                return f"Result: {result}"
            else:
                return "Error: Invalid characters in expression."
        except Exception as e:
            return f"Calculation error: {str(e)}"

    elif tool_name == "get_weather":
        # Simulated weather data — swap this with a real API call in production
        city = tool_input["city"]
        weather_data = {
            "Naples, FL": "Sunny, 84°F, humidity 72%",
            "Miami, FL": "Partly cloudy, 88°F, humidity 80%",
            "Tampa, FL": "Scattered thunderstorms, 79°F, humidity 85%",
        }
        return weather_data.get(city, f"Weather data not available for {city}.")

    elif tool_name == "count_words":
        text = tool_input["text"]
        count = len(text.split())
        return f"Word count: {count} words"

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

The execute_tool function is your tool router. When Claude says "call the calculator with this expression," your code catches that, runs the actual Python logic, and feeds the result back to Claude. It's a simple dispatcher pattern that scales cleanly as you add more tools.

Step 4: Implement the Agentic Loop

This is the heart of every AI agent. The loop runs until Claude either finishes the task or hits your iteration limit. Each cycle: Claude responds, you check if it wants to call a tool, you run the tool, you feed the result back, repeat.

agent.py (continued — add as a method inside ClaudeAgent class)
    def run(self, user_input: str, max_iterations: int = 10) -> str:
        """
        Main agentic loop. Keeps running until Claude stops requesting tool calls
        or we hit the max iteration limit.
        """
        self.add_user_message(user_input)
        iteration = 0

        while iteration < max_iterations:
            iteration += 1

            # Call the Claude API with current conversation history and tools
            response = self.client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=self.system_prompt,
                tools=self.tools,
                messages=self.messages
            )

            # Store Claude's full response in message history
            self.add_assistant_message(response.content)

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

            # If Claude wants to use tools, process each tool call
            if response.stop_reason == "tool_use":
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        tool_name = block.name
                        tool_input = block.input
                        tool_use_id = block.id

                        print(f"  → Calling tool: {tool_name}({tool_input})")

                        # Execute the tool and capture its output
                        result = execute_tool(tool_name, tool_input)
                        print(f"  ← Tool result: {result}")

                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": tool_use_id,
                            "content": result
                        })

                # Feed all tool results back into the conversation
                self.messages.append({
                    "role": "user",
                    "content": tool_results
                })

            else:
                # Unexpected stop reason — break to avoid infinite loop
                break

        return "Max iterations reached. Task may be incomplete."

The stop_reason is what makes this loop smart. When it's "tool_use", Claude isn't done — it's asking for more information. When it's "end_turn", Claude has everything it needs and is giving you the final answer.

⚠️ Always set a max_iterations limit.
Without a cap, a buggy tool or an ambiguous task can cause the agent to loop forever — burning through API tokens and money. Ten iterations is a safe default for most tasks. Raise it only when you have a specific reason to.

Step 5: Test Your Agent with Real Examples

Now let's wire everything together and run some actual tasks. Here's the complete runnable script that pulls in everything we built above.

agent.py (complete file — full version)
import anthropic
import os
import json


# ── Tool Schemas ──────────────────────────────────────────────────────────────

TOOLS = [
    {
        "name": "calculate",
        "description": "Perform basic arithmetic calculations. Use this for any math operations.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression to evaluate, e.g. '15 * 24 + 7'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "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": "count_words",
        "description": "Count the number of words in a given text string.",
        "input_schema": {
            "type": "object",
            "properties": {
                "text": {
                    "type": "string",
                    "description": "The text to count words in"
                }
            },
            "required": ["text"]
        }
    }
]


# ── Tool Executor ─────────────────────────────────────────────────────────────

def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Route tool calls to their actual implementations."""

    if tool_name == "calculate":
        try:
            allowed_chars = set("0123456789+-*/()., ")
            expression = tool_input["expression"]
            if all(c in allowed_chars for c in expression):
                result = eval(expression)
                return f"Result: {result}"
            else:
                return "Error: Invalid characters in expression."
        except Exception as e:
            return f"Calculation error: {str(e)}"

    elif tool_name == "get_weather":
        city = tool_input["city"]
        weather_data = {
            "Naples, FL": "Sunny, 84°F, humidity 72%",
            "Miami, FL": "Partly cloudy, 88°F, humidity 80%",
            "Tampa, FL": "Scattered thunderstorms, 79°F, humidity 85%",
        }
        return weather_data.get(city, f"Weather data not available for {city}.")

    elif tool_name == "count_words":
        text = tool_input["text"]
        count = len(text.split())
        return f"Word count: {count} words"

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


# ── Agent Class ───────────────────────────────────────────────────────────────

class ClaudeAgent:
    def __init__(self, system_prompt: str, tools: list):
        self.client = anthropic.Anthropic(
            api_key=os.environ.get("ANTHROPIC_API_KEY")
        )
        self.model = "claude-sonnet-4-6"
        self.system_prompt = system_prompt
        self.tools = tools
        self.messages = []
        self.max_tokens = 4096

    def add_user_message(self, content: str):
        self.messages.append({"role": "user", "content": content})

    def add_assistant_message(self, content):
        self.messages.append({"role": "assistant", "content": content})

    def run(self, user_input: str, max_iterations: int = 10) -> str:
        self.add_user_message(user_input)
        iteration = 0

        while iteration < max_iterations:
            iteration += 1

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

            self.add_assistant_message(response.content)

            if response.stop_reason == "end_turn":
                for block in response.content:
                    if hasattr(block, "text"):
                        return block.text
                return "Task complete."

            if response.stop_reason == "tool_use":
                tool_results = []

                for block in response.content:
                    if block.type == "tool_use":
                        tool_name = block.name
                        tool_input = block.input
                        tool_use_id = block.id

                        print(f"  → Calling tool: {tool_name}({tool_input})")
                        result = execute_tool(tool_name, tool_input)
                        print(f"  ← Tool result: {result}")

                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": tool_use_id,
                            "content": result
                        })

                self.messages.append({
                    "role": "user",
                    "content": tool_results
                })

            else:
                break

        return "Max iterations reached. Task may be incomplete."


# ── Run Examples ──────────────────────────────────────────────────────────────

if __name__ == "__main__":
    SYSTEM_PROMPT = (
        "You are a helpful assistant with access to tools. "
        "Always use the appropriate tool when a task requires calculation, "
        "weather data, or word counting. Be concise in your responses."
    )

    agent = ClaudeAgent(system_prompt=SYSTEM_PROMPT, tools=TOOLS)

    # Example 1: Math task
    print("\n── Example 1: Calculator ──")
    result = agent.run("What is 347 multiplied by 82, then divided by 4?")
    print(f"\nFinal answer: {result}")

    # Reset conversation history for a fresh task
    agent.messages = []

    # Example 2: Weather lookup
    print("\n── Example 2: Weather ──")
    result = agent.run("What's the weather like in Naples, FL right now?")
    print(f"\nFinal answer: {result}")

    # Reset again
    agent.messages = []

    # Example 3: Multi-step task using two tools
    print("\n── Example 3: Multi-tool task ──")
    result = agent.run(
        "Count the words in this sentence: 'The Naples AI agency builds custom solutions for local businesses.' "
        "Then calculate how much 12 times that word count equals."
    )
    print(f"\nFinal answer: {result}")

Here's what the output looks like when you run this:

sample output
── Example 1: Calculator ──
  → Calling tool: calculate({'expression': '347 * 82 / 4'})
  ← Tool result: Result: 7113.5

Final answer: 347 multiplied by 82 equals 28,454, and when divided by 4, the result is 7,113.5.

── Example 2: Weather ──
  → Calling tool: get_weather({'city': 'Naples, FL'})
  ← Tool result: Sunny, 84°F, humidity 72%

Final answer: The current weather in Naples, FL is sunny with a temperature of 84°F and humidity at 72%.

── Example 3: Multi-tool task ──
  → Calling tool: count_words({'text': 'The Naples AI agency builds custom solutions for local businesses.'})
  ← Tool result: Word count: 10 words
  → Calling tool: calculate({'expression': '12 * 10'})
  ← Tool result: Result: 120

Final answer: The sentence contains 10 words. When multiplied by 12, that gives you 120.

How It Works

Let me walk through what's actually happening under the hood — in plain English. When you call agent.run("some task"), the agent appends your message to its history and sends the whole conversation to Claude along with the tool schemas.

Claude reads the available tools and decides whether it needs to call one. If it does, the API returns a response with stop_reason: "tool_use" and a list of tool calls it wants to make. Your code catches those, runs the actual Python functions, and sends the results back as a new user message.

Claude then reads the tool results, reasons about them, and either calls another tool or writes the final answer. That cycle repeats until stop_reason is "end_turn", which is Claude's way of saying "I'm done, here's your answer." The whole loop is synchronous and deterministic — no surprises.

Common Errors and Fixes

Error 1: AuthenticationError — Invalid API key

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

Fix: Your API key isn't being read correctly. Check that you ran export ANTHROPIC_API_KEY="sk-ant-..." in the same terminal window where you're running the script. Run echo $ANTHROPIC_API_KEY (Mac/Linux) or echo $env:ANTHROPIC_API_KEY (PowerShell) to confirm it's set. Don't wrap the key in extra quotes inside the string.

Error 2: BadRequestError — tool_result blocks in wrong position

anthropic.BadRequestError: Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error', 'message': 'tool_result blocks can only be in a user turn'}}

Fix: Tool results must be sent as a user role message — not as an assistant message. Check your agentic loop and confirm tool results are appended with "