All articles

If you've been searching for a real, working tutorial on how to build AI agents — not toy demos, not hand-wavy pseudocode — you're in the right place. Most guides show you the concept but skip the part where you actually wire everything together. This one doesn't.

By the end of this tutorial, you'll have a fully functional autonomous AI agent built with Python and the Anthropic SDK. It will think, use tools, and loop until it solves the problem you give it.

What You'll Build

You're going to build a research agent that can search for information, run calculations, and report back with a final answer — all without you clicking anything. It uses Claude's tool use feature to decide which tools to call, executes them in Python, and feeds the results back into the loop.

The finished agent handles multi-step reasoning on its own. Give it a question, walk away, and it figures out the steps. That's what makes it an agent and not just a chatbot.

📦 Full Source Code Note: The complete working agent is built section by section below. Every snippet connects to the next one. By Step 5, you'll have the full file ready to run. If you want to jump ahead, scroll to the Full Source Code section — but I'd recommend reading the steps so you understand what each piece does.

Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic Python knowledge — you don't need to be an expert
  • anthropic package installed: pip install anthropic
  • A terminal and a text editor (VS Code works great)

Full Source Code

💡 How to use this: The code below is the complete, working agent in one file. The steps that follow break it down and explain every section. Copy the full file at the end, set your API key as an environment variable, and run it with python agent.py.
agent.py (complete file)
import os
import json
import logging
import anthropic

# Configure logging so we can see what the agent is thinking
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Tool definitions — these tell Claude what capabilities the agent has
# ---------------------------------------------------------------------------
TOOLS = [
    {
        "name": "calculator",
        "description": "Perform arithmetic calculations. Use this whenever you need to compute a number.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A valid Python math expression, e.g. '(42 * 3) / 7'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_weather",
        "description": "Get the current weather for a city. Returns temperature in Fahrenheit and conditions.",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "The name of the city, e.g. 'Naples, FL'"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "lookup_fact",
        "description": "Look up a specific fact or piece of information from a local knowledge base.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The fact or information to look up"
                }
            },
            "required": ["query"]
        }
    }
]

# ---------------------------------------------------------------------------
# Simulated tool execution functions
# ---------------------------------------------------------------------------
def run_calculator(expression: str) -> str:
    """Safely evaluate a math expression."""
    try:
        # Using eval with restricted globals for safety
        result = eval(expression, {"__builtins__": {}}, {})
        return f"Result: {result}"
    except Exception as e:
        return f"Calculator error: {str(e)}"


def run_get_weather(city: str) -> str:
    """Simulated weather lookup — replace with a real API in production."""
    weather_data = {
        "naples": {"temp": 84, "conditions": "Sunny, humidity 72%"},
        "miami": {"temp": 88, "conditions": "Partly cloudy"},
        "tampa": {"temp": 82, "conditions": "Scattered thunderstorms"},
    }
    key = city.lower().split(",")[0].strip()
    data = weather_data.get(key, {"temp": 75, "conditions": "Clear skies"})
    return f"{city}: {data['temp']}°F, {data['conditions']}"


def run_lookup_fact(query: str) -> str:
    """Simulated knowledge base lookup — replace with a real DB or vector search."""
    facts = {
        "naples population": "Naples, FL has a population of approximately 22,000 in the city proper, with over 375,000 in Collier County.",
        "naples ai": "Naples AI is a custom AI solutions agency in Southwest Florida, founded by Chris Mejias, part of Benevolent Capital Group.",
        "florida sales tax": "Florida's state sales tax rate is 6%, with counties adding up to 2.5% on top.",
    }
    query_lower = query.lower()
    for key, value in facts.items():
        if key in query_lower:
            return value
    return f"No specific fact found for: '{query}'. Try rephrasing your query."


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Route a tool call to the correct function and return the result."""
    logger.info(f"Executing tool: {tool_name} with input: {tool_input}")
    if tool_name == "calculator":
        return run_calculator(tool_input["expression"])
    elif tool_name == "get_weather":
        return run_get_weather(tool_input["city"])
    elif tool_name == "lookup_fact":
        return run_lookup_fact(tool_input["query"])
    else:
        return f"Unknown tool: {tool_name}"


# ---------------------------------------------------------------------------
# Main Agent class
# ---------------------------------------------------------------------------
class ClaudeAgent:
    def __init__(self, system_prompt: str = None):
        # Initialize the Anthropic client — reads ANTHROPIC_API_KEY from env
        self.client = anthropic.Anthropic()
        self.model = "claude-sonnet-4-6"
        self.max_tokens = 4096
        self.max_iterations = 10  # Safety cap to prevent infinite loops
        self.system_prompt = system_prompt or (
            "You are a helpful research agent. Use the tools available to you "
            "to answer the user's question accurately. Think step by step. "
            "When you have enough information, provide a clear final answer."
        )

    def run(self, user_message: str) -> str:
        """
        Run the agentic loop for a given user message.
        Keeps looping until Claude returns a final text response
        or the iteration limit is hit.
        """
        logger.info(f"Agent starting with message: {user_message}")
        messages = [{"role": "user", "content": user_message}]
        iteration = 0

        while iteration < self.max_iterations:
            iteration += 1
            logger.info(f"--- Agent iteration {iteration} ---")

            # Call Claude with the current message history and tool definitions
            response = self.client.messages.create(
                model=self.model,
                max_tokens=self.max_tokens,
                system=self.system_prompt,
                tools=TOOLS,
                messages=messages
            )

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

            # If Claude is done thinking and has a final answer, return it
            if response.stop_reason == "end_turn":
                final_text = ""
                for block in response.content:
                    if hasattr(block, "text"):
                        final_text += block.text
                logger.info("Agent finished — returning final answer.")
                return final_text.strip()

            # If Claude wants to use a tool, handle all tool calls in this turn
            if response.stop_reason == "tool_use":
                # Add Claude's response (with tool calls) to the message history
                messages.append({
                    "role": "assistant",
                    "content": response.content
                })

                # Collect results for every tool Claude requested this turn
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        logger.info(f"Tool requested: {block.name}")
                        result = execute_tool(block.name, block.input)
                        logger.info(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 — log and break
                logger.warning(f"Unexpected stop reason: {response.stop_reason}")
                break

        logger.warning("Agent hit max iterations without a final answer.")
        return "Agent reached maximum iterations without completing the task."


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
    agent = ClaudeAgent()

    # Test query that requires multiple tools
    query = (
        "What's the weather like in Naples, FL today? "
        "Also, if it's above 80°F, calculate how many degrees above 80 it is, "
        "and tell me something interesting about Naples."
    )

    print(f"\nUser: {query}\n")
    answer = agent.run(query)
    print(f"\nAgent: {answer}\n")
        

Step 1: Set Up Your Claude API Environment

Before anything else, install the Anthropic SDK and set your API key as an environment variable. Never hardcode your key — that's the fastest way to accidentally push it to GitHub.

terminal
pip install anthropic

# On Mac/Linux
export ANTHROPIC_API_KEY="sk-ant-your-key-here"

# On Windows (Command Prompt)
set ANTHROPIC_API_KEY=sk-ant-your-key-here

# On Windows (PowerShell)
$env:ANTHROPIC_API_KEY="sk-ant-your-key-here"
        

The Anthropic client automatically reads the ANTHROPIC_API_KEY environment variable when you call anthropic.Anthropic(). You don't need to pass it in manually — which keeps your code clean and safe.

agent.py — client initialization
import anthropic

class ClaudeAgent:
    def __init__(self, system_prompt: str = None):
        # No API key argument needed — SDK reads from environment automatically
        self.client = anthropic.Anthropic()
        self.model = "claude-sonnet-4-6"
        self.max_tokens = 4096
        self.max_iterations = 10  # Prevents runaway loops
        self.system_prompt = system_prompt or (
            "You are a helpful research agent. Use the tools available to you "
            "to answer the user's question accurately. Think step by step. "
            "When you have enough information, provide a clear final answer."
        )
        
⚠️ Model name matters: Use claude-sonnet-4-6 exactly. Anthropic model names are case-sensitive and version-specific. If you get a model not found error, this is the first thing to check.

Step 2: Define Your Agent's Tools

Tools are how Claude interacts with the real world. You define them as a list of JSON schemas, and Claude decides when and how to call them based on the conversation. Think of this like giving your agent a toolbox and letting it pick the right wrench.

Each tool definition has three parts: a name, a description Claude uses to decide when to call it, and an input schema that specifies what arguments it expects.

agent.py — tool definitions
import json

TOOLS = [
    {
        "name": "calculator",
        "description": "Perform arithmetic calculations. Use this whenever you need to compute a number.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A valid Python math expression, e.g. '(42 * 3) / 7'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_weather",
        "description": "Get the current weather for a city. Returns temperature in Fahrenheit and conditions.",
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "The name of the city, e.g. 'Naples, FL'"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "lookup_fact",
        "description": "Look up a specific fact or piece of information from a local knowledge base.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The fact or information to look up"
                }
            },
            "required": ["query"]
        }
    }
]
        

Write your descriptions like you're explaining the tool to a smart intern who's never seen your code. The clearer the description, the better Claude's decisions about when to use it. Vague descriptions lead to incorrect tool calls.

Step 3: Implement the Agent Loop with Tool Use

This is the core of every AI agent. The loop sends a message to Claude, checks if it wants to use a tool, executes that tool, sends the result back, and repeats until Claude has a final answer. That back-and-forth is what makes an agent different from a single API call.

agent.py — agentic loop
def run(self, user_message: str) -> str:
    """
    Run the agentic loop for a given user message.
    Keeps looping until Claude returns a final text response
    or the iteration limit is hit.
    """
    import logging
    logger = logging.getLogger(__name__)

    logger.info(f"Agent starting with message: {user_message}")
    messages = [{"role": "user", "content": user_message}]
    iteration = 0

    while iteration < self.max_iterations:
        iteration += 1
        logger.info(f"--- Agent iteration {iteration} ---")

        # Call Claude with the full conversation history each time
        response = self.client.messages.create(
            model=self.model,
            max_tokens=self.max_tokens,
            system=self.system_prompt,
            tools=TOOLS,
            messages=messages
        )

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

        # "end_turn" means Claude is done — extract and return the text
        if response.stop_reason == "end_turn":
            final_text = ""
            for block in response.content:
                if hasattr(block, "text"):
                    final_text += block.text
            logger.info("Agent finished — returning final answer.")
            return final_text.strip()

        # "tool_use" means Claude wants to call one or more tools
        if response.stop_reason == "tool_use":
            # Append Claude's full response to conversation history
            messages.append({
                "role": "assistant",
                "content": response.content
            })

            # Handle every tool call Claude made in this turn
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    logger.info(f"Tool requested: {block.name}")
                    result = execute_tool(block.name, block.input)
                    logger.info(f"Tool result: {result}")
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,  # Must match the tool_use block id
                        "content": result
                    })

            # Send all results back in one user message
            messages.append({
                "role": "user",
                "content": tool_results
            })

        else:
            logger.warning(f"Unexpected stop reason: {response.stop_reason}")
            break

    logger.warning("Agent hit max iterations without a final answer.")
    return "Agent reached maximum iterations without completing the task."
        
🔑 Key pattern: You must pass tool_use_id back with each tool result. If the ID doesn't match, the API will reject your request. Claude generates the ID — you just pass it through unchanged.

Step 4: Handle Tool Execution and Responses

When Claude asks for a tool, your code runs the actual function and sends the result back. This is where the agent connects to real systems — databases, APIs, file systems, whatever you need. For this tutorial, I'm using simulated functions so you can run it without external dependencies.

agent.py — tool execution
def run_calculator(expression: str) -> str:
    """Safely evaluate a math expression."""
    try:
        # Restricting builtins prevents code injection via the expression string
        result = eval(expression, {"__builtins__": {}}, {})
        return f"Result: {result}"
    except Exception as e:
        return f"Calculator error: {str(e)}"


def run_get_weather(city: str) -> str:
    """Simulated weather lookup — swap this for a real API like OpenWeatherMap."""
    weather_data = {
        "naples": {"temp": 84, "conditions": "Sunny, humidity 72%"},
        "miami": {"temp": 88, "conditions": "Partly cloudy"},
        "tampa": {"temp": 82, "conditions": "Scattered thunderstorms"},
    }
    key = city.lower().split(",")[0].strip()
    data = weather_data.get(key, {"temp": 75, "conditions": "Clear skies"})
    return f"{city}: {data['temp']}°F, {data['conditions']}"


def run_lookup_fact(query: str) -> str:
    """Simulated knowledge base — swap for a real vector DB like Pinecone or pgvector."""
    facts = {
        "naples population": "Naples, FL has a population of approximately 22,000 in the city proper, with over 375,000 in Collier County.",
        "naples ai": "Naples AI is a custom AI solutions agency in Southwest Florida, founded by Chris Mejias, part of Benevolent Capital Group.",
        "florida sales tax": "Florida's state sales tax rate is 6%, with counties adding up to 2.5% on top.",
    }
    query_lower = query.lower()
    for key, value in facts.items():
        if key in query_lower:
            return value
    return f"No specific fact found for: '{query}'. Try rephrasing your query."


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Route a tool call to the correct function."""
    if tool_name == "calculator":
        return run_calculator(tool_input["expression"])
    elif tool_name == "get_weather":
        return run_get_weather(tool_input["city"])
    elif tool_name == "lookup_fact":
        return run_lookup_fact(tool_input["query"])
    else:
        return f"Unknown tool: {tool_name}"
        

The execute_tool function is intentionally simple — it's just a router. When you add new tools, you add them to TOOLS, write the function, and add one line to this router. That's the whole pattern.

Step 5: Add Error Handling and Logging

A working agent without logging is like driving blindfolded. You need to see what Claude is thinking, which tools it called, and what the results were. This is also how you debug when something goes wrong.

agent.py — error handling and logging
import os
import json
import logging
import anthropic

# Set up logging before anything else runs
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Route tool calls with error handling so one bad tool doesn't crash the agent."""
    logger.info(f"Executing tool: {tool_name} with input: {tool_input}")
    try:
        if tool_name == "calculator":
            return run_calculator(tool_input["expression"])
        elif tool_name == "get_weather":
            return run_get_weather(tool_input["city"])
        elif tool_name == "lookup_fact":
            return run_lookup_fact(tool_input["query"])
        else:
            return f"Unknown tool: {tool_name}"
    except KeyError as e:
        #