← Back to Blog

What You'll Build

If you've been searching for a real how to build AI agents tutorial — not a toy demo, but something you could actually ship — you're in the right place. 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 until it completes a task.

The agent runs in under 100 lines of clean Python and uses the official Anthropic SDK. You'll walk away understanding the agentic loop pattern well enough to extend it into your own projects.

📦 Full Source Code Note: The complete working agent is built piece by piece in the steps below. Each section adds onto the last, so by Step 5 you have the entire file. No placeholder code — everything here runs.

Prerequisites

  • Python 3.9 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • anthropic SDK installed: pip install anthropic
  • A terminal and a text editor or IDE

Step 1: Set Up Your Claude API Environment

First, install the Anthropic SDK and store your API key as an environment variable. Never hardcode keys in source files — especially if you're pushing to GitHub.

terminal
pip install anthropic
export ANTHROPIC_API_KEY="sk-ant-your-key-here"

Now create a new file called agent.py and confirm the SDK connects correctly with a quick smoke test.

agent.py
import os
import anthropic

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

# Quick connection test — remove this after confirming it works
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=64,
    messages=[{"role": "user", "content": "Say hello in one word."}]
)

print(response.content[0].text)

Run it with python agent.py. If you see something like "Hello!" printed back, your environment is good to go.

💡 Tip: If you're on Windows, set the environment variable with set ANTHROPIC_API_KEY=sk-ant-your-key-here in Command Prompt, or use a .env file with the python-dotenv package.

Step 2: Define Your Agent's Tools and Schema

Tools are what turn a regular LLM call into an agent. You define each tool as a JSON schema — Claude reads the schema, decides when to use the tool, and returns structured arguments you can execute in Python.

For this tutorial, I'm building an agent that can check weather data and perform calculations. These are simple, but they demonstrate every concept you need for real-world tools like database lookups or API calls.

agent.py
import os
import json
import anthropic

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

# Tool schemas tell Claude what each function does and what arguments it expects
TOOLS = [
    {
        "name": "get_weather",
        "description": (
            "Returns the current weather for a given city. "
            "Use this when the user asks about weather conditions."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "The city name, e.g. 'Naples, FL'"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "Temperature unit to return"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "calculate",
        "description": (
            "Evaluates a basic math expression and returns the result. "
            "Use this for arithmetic the user asks you to compute."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression as a string, e.g. '42 * 7 + 15'"
                }
            },
            "required": ["expression"]
        }
    }
]

The input_schema block follows JSON Schema conventions. The required array tells Claude which fields it must provide — it won't call your tool without them.

Step 3: Create the Main Agent Class

Now let's build the class that wraps everything. The ClaudeAgent class holds the conversation history, the tool definitions, and the logic to send messages to Claude.

agent.py
import os
import json
import logging
import anthropic

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

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

TOOLS = [
    {
        "name": "get_weather",
        "description": (
            "Returns the current weather for a given city. "
            "Use this when the user asks about weather conditions."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "The city name, e.g. 'Naples, FL'"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "Temperature unit to return"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "calculate",
        "description": (
            "Evaluates a basic math expression and returns the result. "
            "Use this for arithmetic the user asks you to compute."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression as a string, e.g. '42 * 7 + 15'"
                }
            },
            "required": ["expression"]
        }
    }
]


class ClaudeAgent:
    def __init__(self, system_prompt: str = "You are a helpful assistant."):
        self.client = client
        self.model = "claude-sonnet-4-6"
        self.tools = TOOLS
        self.system_prompt = system_prompt
        # Conversation history persists across turns in a session
        self.messages: list[dict] = []
        self.max_tokens = 4096

    def _call_claude(self) -> anthropic.types.Message:
        """Send the current message history to Claude and return the response."""
        logger.info("Calling Claude with %d messages in history", len(self.messages))
        return self.client.messages.create(
            model=self.model,
            max_tokens=self.max_tokens,
            system=self.system_prompt,
            tools=self.tools,
            messages=self.messages
        )

    def chat(self, user_input: str) -> str:
        """Add a user message and kick off the agentic loop."""
        self.messages.append({"role": "user", "content": user_input})
        return self._run_loop()

    def _run_loop(self) -> str:
        """Agentic loop — keeps running until Claude returns a final text response."""
        while True:
            response = self._call_claude()
            logger.info("Stop reason: %s", response.stop_reason)

            # Claude is done — return the final text answer
            if response.stop_reason == "end_turn":
                final_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    ""
                )
                # Add assistant response to history for multi-turn memory
                self.messages.append({"role": "assistant", "content": response.content})
                return final_text

            # Claude wants to use a tool
            if response.stop_reason == "tool_use":
                self.messages.append({"role": "assistant", "content": response.content})
                tool_results = self._execute_tools(response.content)
                # Feed tool results back so Claude can continue reasoning
                self.messages.append({"role": "user", "content": tool_results})
                continue

            # Unexpected stop reason — bail out gracefully
            logger.warning("Unexpected stop reason: %s", response.stop_reason)
            return "I encountered an unexpected state. Please try again."

The _run_loop method is the heart of this. It keeps calling Claude, executing tools when asked, and feeding results back until Claude signals it's done with end_turn.

Step 4: Implement the Tool Execution Loop

When Claude decides to use a tool, it returns a tool_use block with the tool name and the arguments it wants to pass. Your job is to route that to the right Python function and return the result.

agent.py (add these methods to ClaudeAgent)
    def _execute_tools(self, content_blocks: list) -> list[dict]:
        """
        Loop through content blocks, find tool_use blocks,
        run each tool, and return a list of tool_result messages.
        """
        tool_results = []

        for block in content_blocks:
            if block.type != "tool_use":
                continue

            tool_name = block.name
            tool_input = block.input
            tool_use_id = block.id

            logger.info("Executing tool: %s with input: %s", tool_name, tool_input)

            try:
                result = self._dispatch_tool(tool_name, tool_input)
            except Exception as e:
                # Return the error as a string so Claude can handle it gracefully
                result = f"Error executing {tool_name}: {str(e)}"
                logger.error("Tool execution failed: %s", e)

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

        return tool_results

    def _dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
        """Route tool calls to the correct Python function."""
        if tool_name == "get_weather":
            return self._tool_get_weather(
                city=tool_input["city"],
                unit=tool_input.get("unit", "fahrenheit")
            )
        elif tool_name == "calculate":
            return self._tool_calculate(expression=tool_input["expression"])
        else:
            raise ValueError(f"Unknown tool: {tool_name}")

    def _tool_get_weather(self, city: str, unit: str = "fahrenheit") -> str:
        """
        Simulated weather tool. In production, replace this with a real
        API call to OpenWeatherMap, WeatherAPI, or similar.
        """
        mock_data = {
            "naples": {"temp_f": 84, "temp_c": 29, "condition": "Sunny"},
            "miami": {"temp_f": 87, "temp_c": 31, "condition": "Partly Cloudy"},
            "new york": {"temp_f": 62, "temp_c": 17, "condition": "Overcast"},
        }

        city_key = city.lower().split(",")[0].strip()
        weather = mock_data.get(city_key, {"temp_f": 72, "temp_c": 22, "condition": "Clear"})

        temp = weather["temp_f"] if unit == "fahrenheit" else weather["temp_c"]
        unit_symbol = "°F" if unit == "fahrenheit" else "°C"

        return f"{city}: {weather['condition']}, {temp}{unit_symbol}"

    def _tool_calculate(self, expression: str) -> str:
        """
        Safe math evaluator using a restricted environment.
        Never use bare eval() on untrusted input in production.
        """
        allowed_names = {"__builtins__": {}}
        # Only expose safe math operations
        safe_math = {
            "abs": abs, "round": round, "min": min, "max": max,
            "sum": sum, "pow": pow
        }
        allowed_names.update(safe_math)

        try:
            result = eval(expression, allowed_names)  # noqa: S307
            return str(result)
        except Exception as e:
            return f"Math error: {str(e)}"
⚠️ Security Note: The calculate tool uses a restricted eval() with no builtins and a limited safe-math namespace. For production, use a proper math parsing library like simpleeval instead.

Step 5: Add Error Handling and Logging

Now let's wire everything together with a main function that includes proper error handling. This is the part I see people skip — and then they can't debug when something goes wrong.

agent.py (complete final file)
import os
import json
import logging
import anthropic

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)

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

TOOLS = [
    {
        "name": "get_weather",
        "description": (
            "Returns the current weather for a given city. "
            "Use this when the user asks about weather conditions."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "The city name, e.g. 'Naples, FL'"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "Temperature unit to return"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "calculate",
        "description": (
            "Evaluates a basic math expression and returns the result. "
            "Use this for arithmetic the user asks you to compute."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A math expression as a string, e.g. '42 * 7 + 15'"
                }
            },
            "required": ["expression"]
        }
    }
]


class ClaudeAgent:
    def __init__(self, system_prompt: str = "You are a helpful assistant."):
        self.client = client
        self.model = "claude-sonnet-4-6"
        self.tools = TOOLS
        self.system_prompt = system_prompt
        self.messages: list[dict] = []
        self.max_tokens = 4096

    def _call_claude(self) -> anthropic.types.Message:
        logger.info("Calling Claude with %d messages in history", len(self.messages))
        return self.client.messages.create(
            model=self.model,
            max_tokens=self.max_tokens,
            system=self.system_prompt,
            tools=self.tools,
            messages=self.messages
        )

    def chat(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})
        return self._run_loop()

    def _run_loop(self) -> str:
        while True:
            response = self._call_claude()
            logger.info("Stop reason: %s", response.stop_reason)

            if response.stop_reason == "end_turn":
                final_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    ""
                )
                self.messages.append({"role": "assistant", "content": response.content})
                return final_text

            if response.stop_reason == "tool_use":
                self.messages.append({"role": "assistant", "content": response.content})
                tool_results = self._execute_tools(response.content)
                self.messages.append({"role": "user", "content": tool_results})
                continue

            logger.warning("Unexpected stop reason: %s", response.stop_reason)
            return "I encountered an unexpected state. Please try again."

    def _execute_tools(self, content_blocks: list) -> list[dict]:
        tool_results = []

        for block in content_blocks:
            if block.type != "tool_use":
                continue

            tool_name = block.name
            tool_input = block.input
            tool_use_id = block.id

            logger.info("Executing tool: %s with input: %s", tool_name, tool_input)

            try:
                result = self._dispatch_tool(tool_name, tool_input)
            except Exception as e:
                result = f"Error executing {tool_name}: {str(e)}"
                logger.error("Tool execution failed: %s", e)

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

        return tool_results

    def _dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
        if tool_name == "get_weather":
            return self._tool_get_weather(
                city=tool_input["city"],
                unit=tool_input.get("unit", "fahrenheit")
            )
        elif tool_name == "calculate":
            return self._tool_calculate(expression=tool_input["expression"])
        else:
            raise ValueError(f"Unknown tool: {tool_name}")

    def _tool_get_weather(self, city: str, unit: str = "fahrenheit") -> str:
        mock_data = {
            "naples": {"temp_f": 84, "temp_c": 29, "condition": "Sunny"},
            "miami": {"temp_f": 87, "temp_c": 31, "condition": "Partly Cloudy"},
            "new york": {"temp_f": 62, "temp_c": 17, "condition": "Overcast"},
        }

        city_key = city.lower().split(",")[0].strip()
        weather = mock_data.get(city_key, {"temp_f": 72, "temp_c": 22, "condition": "Clear"})

        temp = weather["temp_f"] if unit == "fahrenheit" else weather["temp_c"]
        unit_symbol = "°F" if unit == "fahrenheit" else "°C"

        return f"{city}: {weather['condition']}, {temp}{unit_symbol}"

    def _tool_calculate(self, expression: str) -> str:
        allowed_names = {"__builtins__": {}}
        safe_math = {
            "abs": abs, "round": round, "min": min, "max": max,
            "sum": sum, "pow": pow
        }
        allowed_names.update(safe_math)

        try:
            result = eval(expression, allowed_names)  # noqa: S307
            return str(result)
        except Exception as e:
            return f"Math error: {str(e)}"


def main():
    print("Claude AI Agent — type 'quit' to exit\n")

    agent = ClaudeAgent(
        system_prompt=(
            "You are a helpful assistant with access to weather data and a calculator. "
            "Use your tools whenever they would give a better answer than guessing."
        )
    )

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

        if not user_input:
            continue

        if user_input.lower() in ("quit", "exit", "q"):
            print("Goodbye!")
            break

        try:
            response = agent.chat(user_input)
            print(f"\nAgent: {response}\n")
        except anthropic.APIConnectionError:
            print("Connection error — check your internet and try again.")
        except anthropic.AuthenticationError:
            print("Invalid API key — check your ANTHROPIC_API_KEY environment variable.")
        except anthropic.RateLimitError:
            print("Rate limit hit — wait a moment and try again.")
        except anthropic.APIStatusError as e:
            print(f"API error {e.status_code}: {e.message}")


if __name__ == "__main__":
    main()

That's the complete agent. Under 120 lines of actual logic, and it handles the full tool-use cycle.

Example Output and Debugging

Here's what a real session looks like when you run this. I'm including the log lines too so you can see exactly what's happening under the hood.

terminal output
Claude AI Agent — type 'quit' to exit

You: What's the weather like