← Back to Blog

If you've been searching for a real, working example of how to build AI agents with Claude API, you're in the right place. Most tutorials either stop at "send a message and get a response" or they throw you into enterprise-scale architecture before you've written a single line of agent code. This tutorial skips both extremes.

I'm going to walk you through building a genuine autonomous AI agent using Python and the Anthropic SDK. By the end, you'll have a working agent that can reason, call tools, and loop through decisions on its own — not just a chatbot that answers questions.

What You'll Build

You'll build a task-driven AI agent that uses Claude to decide which tools to call, executes those tools in Python, and keeps looping until it completes the task — or knows it can't. The agent will handle web search simulation, basic math calculations, and text file operations as its tool set.

This is the same foundation we use at Naples AI when building custom automation agents for local businesses. Once you understand this loop, you can swap in any tools you need — database queries, API calls, CRM updates, whatever your workflow requires.

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 know AI
  • anthropic Python package installed (pip install anthropic)
  • A terminal and a code editor
📦 Full Source Code
The complete, working agent code is broken into steps below. Each section builds on the last, so by Step 5 you'll have the entire file assembled and running. If you want to jump ahead, scroll to Step 3 — that's where the full agent class comes together.

Step 1: Setting Up Your Claude API Environment

First, install the Anthropic SDK if you haven't already. Open your terminal and run pip install anthropic. Then create a file called agent.py in a fresh project folder.

You'll store your API key as an environment variable — never hardcode it in your source file. On Mac or Linux, run export ANTHROPIC_API_KEY="your-key-here" in your terminal before running the script. On Windows, use set ANTHROPIC_API_KEY=your-key-here.

agent.py — environment setup and imports
import os
import json
import anthropic

# Load the API key from environment — never hardcode this
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
    raise ValueError("ANTHROPIC_API_KEY environment variable not set.")

# Initialize the Anthropic client
client = anthropic.Anthropic(api_key=api_key)

# We'll use claude-sonnet-4-6 throughout this tutorial
MODEL = "claude-sonnet-4-6"

Run this file with python agent.py to confirm there are no import errors before moving on. If you see a ModuleNotFoundError for anthropic, double-check that your pip install ran in the same Python environment you're using to run the script.

Step 2: Creating Your First Agent Class

The agent class is the brain of the whole system. It holds the conversation history, knows which tools are available, and decides when to stop looping. I keep this as a clean Python class so it's easy to extend later.

The key design decision here is that the agent owns its own message history. Every tool result gets appended back into the conversation so Claude always has full context on what it already tried.

agent.py — Agent class definition
import os
import json
import anthropic

api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
    raise ValueError("ANTHROPIC_API_KEY environment variable not set.")

client = anthropic.Anthropic(api_key=api_key)
MODEL = "claude-sonnet-4-6"


class Agent:
    def __init__(self, system_prompt: str, tools: list):
        self.system_prompt = system_prompt
        self.tools = tools          # List of tool definitions Claude can call
        self.messages = []          # Running conversation history
        self.max_iterations = 10    # Safety limit — prevents infinite loops

    def add_user_message(self, content: str):
        """Add a user turn to the conversation history."""
        self.messages.append({"role": "user", "content": content})

    def add_tool_result(self, tool_use_id: str, result: str):
        """Append a tool result back into the conversation so Claude can use it."""
        self.messages.append({
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                }
            ]
        })

    def call_claude(self) -> anthropic.types.Message:
        """Send the current message history to Claude and get a response."""
        response = client.messages.create(
            model=MODEL,
            max_tokens=4096,
            system=self.system_prompt,
            tools=self.tools,
            messages=self.messages
        )
        return response

Notice the max_iterations guard on line 16. This is not optional. Without a stopping condition, a buggy tool can cause your agent to loop forever and burn through API credits. Ten iterations is generous for most real tasks.

Step 3: Defining Tool Functions for Your Agent

Tools are the hands of your agent — they let Claude actually do things in the world instead of just talking about them. Each tool has two parts: a JSON schema that tells Claude what the tool does and what parameters it needs, and a Python function that actually runs when Claude calls it.

I'm defining three tools here: a calculator, a mock web search, and a text file writer. These cover the three most common patterns — computation, data retrieval, and side effects.

agent.py — tool definitions and execution functions
import os
import json
import math
import anthropic

api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
    raise ValueError("ANTHROPIC_API_KEY environment variable not set.")

client = anthropic.Anthropic(api_key=api_key)
MODEL = "claude-sonnet-4-6"


# ── Tool schemas — Claude reads these to understand what it can call ──────────

TOOL_DEFINITIONS = [
    {
        "name": "calculator",
        "description": "Evaluates a mathematical expression and returns the result. Use this for any arithmetic, percentages, or formulas.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A valid Python math expression, e.g. '15 * 1.08' or 'math.sqrt(144)'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "web_search",
        "description": "Searches the web for information on a topic and returns a summary. Use this to look up facts, prices, or current data.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query string"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "write_file",
        "description": "Writes text content to a local file. Use this to save reports, summaries, or any output that should be stored.",
        "input_schema": {
            "type": "object",
            "properties": {
                "filename": {
                    "type": "string",
                    "description": "The name of the file to write, e.g. 'report.txt'"
                },
                "content": {
                    "type": "string",
                    "description": "The text content to write into the file"
                }
            },
            "required": ["filename", "content"]
        }
    }
]


# ── Python functions that actually run when Claude calls each tool ────────────

def run_calculator(expression: str) -> str:
    """Safely evaluate a math expression using Python's math module."""
    try:
        # Restrict eval to math functions only — never eval raw user input unsanitized
        allowed = {k: v for k, v in math.__dict__.items() if not k.startswith("_")}
        allowed["__builtins__"] = {}
        result = eval(expression, allowed)  # noqa: S307
        return str(result)
    except Exception as e:
        return f"Calculator error: {str(e)}"


def run_web_search(query: str) -> str:
    """
    Mock web search — returns simulated results.
    In production, replace this with a real search API like Serper or Brave.
    """
    mock_results = {
        "naples florida population": "Naples, Florida has a population of approximately 22,000 within city limits, with the greater Naples metro area reaching over 375,000 residents as of 2024.",
        "average home price naples florida": "The median home price in Naples, Florida is approximately $650,000 as of early 2026, reflecting continued demand in the luxury real estate market.",
        "python anthropic sdk": "The Anthropic Python SDK allows developers to interact with Claude models via the Anthropic API. Install with pip install anthropic.",
    }
    # Return a matching mock result or a generic fallback
    for key, value in mock_results.items():
        if any(word in query.lower() for word in key.split()):
            return value
    return f"Search results for '{query}': No specific data found. Please refine your query or consult a live search API."


def run_write_file(filename: str, content: str) -> str:
    """Write content to a file in the current directory."""
    try:
        with open(filename, "w") as f:
            f.write(content)
        return f"Successfully wrote {len(content)} characters to '{filename}'."
    except Exception as e:
        return f"File write error: {str(e)}"


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """Route tool calls from Claude to the correct Python function."""
    if tool_name == "calculator":
        return run_calculator(tool_input["expression"])
    elif tool_name == "web_search":
        return run_web_search(tool_input["query"])
    elif tool_name == "write_file":
        return run_write_file(tool_input["filename"], tool_input["content"])
    else:
        return f"Unknown tool: {tool_name}"
⚠️ Security Note on eval()
The calculator uses a restricted eval() that only has access to the math module. Never pass raw user input into an unrestricted eval(). In a production system, consider using a proper expression parser library like simpleeval instead.

Step 4: Building the Agent's Decision Loop

This is the core of the whole tutorial — the agentic loop. Claude looks at the task, decides whether to call a tool or give a final answer, you run the tool if needed, and then you feed the result back to Claude. Repeat until done.

The loop stops when Claude returns stop_reason == "end_turn" (it's finished) or stop_reason == "tool_use" transitions to a text response after all tools complete. The max_iterations counter catches any edge cases where neither happens cleanly.

agent.py — agentic loop with stopping conditions
import os
import json
import math
import anthropic

api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
    raise ValueError("ANTHROPIC_API_KEY environment variable not set.")

client = anthropic.Anthropic(api_key=api_key)
MODEL = "claude-sonnet-4-6"


class Agent:
    def __init__(self, system_prompt: str, tools: list):
        self.system_prompt = system_prompt
        self.tools = tools
        self.messages = []
        self.max_iterations = 10

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

    def add_tool_result(self, tool_use_id: str, result: str):
        self.messages.append({
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                }
            ]
        })

    def call_claude(self) -> anthropic.types.Message:
        response = client.messages.create(
            model=MODEL,
            max_tokens=4096,
            system=self.system_prompt,
            tools=self.tools,
            messages=self.messages
        )
        return response

    def run(self, task: str) -> str:
        """
        Main entry point. Pass in a task string and get back the agent's final answer.
        This is the agentic loop — it runs until Claude signals it's done.
        """
        print(f"\n🚀 Starting task: {task}\n{'─' * 60}")
        self.add_user_message(task)

        iterations = 0

        while iterations < self.max_iterations:
            iterations += 1
            print(f"  [Iteration {iterations}] Calling Claude...")

            response = self.call_claude()

            # Append Claude's response to history so it has context next iteration
            self.messages.append({"role": "assistant", "content": response.content})

            # Check if Claude is done — no more tool calls needed
            if response.stop_reason == "end_turn":
                final_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    "Task completed with no text response."
                )
                print(f"\n✅ Agent finished after {iterations} iteration(s).")
                return final_text

            # If Claude wants to use tools, execute each one and feed results back
            if response.stop_reason == "tool_use":
                tool_calls = [block for block in response.content if block.type == "tool_use"]

                for tool_call in tool_calls:
                    tool_name = tool_call.name
                    tool_input = tool_call.input
                    tool_use_id = tool_call.id

                    print(f"  🔧 Tool call: {tool_name}({json.dumps(tool_input)})")
                    result = execute_tool(tool_name, tool_input)
                    print(f"  📥 Tool result: {result[:120]}...")  # Trim long results in logs

                    # Feed the result back into the conversation
                    self.add_tool_result(tool_use_id, result)

                # Loop continues — Claude will now process the tool results
                continue

            # Fallback: if stop_reason is something unexpected, extract any text and stop
            fallback_text = next(
                (block.text for block in response.content if hasattr(block, "text")),
                f"Agent stopped with reason: {response.stop_reason}"
            )
            return fallback_text

        # If we hit max iterations, return whatever Claude last said
        return f"Agent reached maximum iterations ({self.max_iterations}). Last response may be incomplete."

The loop is intentionally linear — one iteration at a time, full stop. Some tutorials try to parallelize tool calls, but for your first agent, keeping it sequential makes debugging dramatically easier. You can optimize for concurrency later once the logic is solid.

Step 5: Testing With Real-World Examples

Now let's put it all together and run the agent against a real task. I'm using a scenario that reflects something we'd actually build for a Naples real estate client — research local market data, run a calculation, and save a summary report.

agent.py — complete runnable file with test examples
import os
import json
import math
import anthropic

api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
    raise ValueError("ANTHROPIC_API_KEY environment variable not set.")

client = anthropic.Anthropic(api_key=api_key)
MODEL = "claude-sonnet-4-6"

TOOL_DEFINITIONS = [
    {
        "name": "calculator",
        "description": "Evaluates a mathematical expression and returns the result.",
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {"type": "string", "description": "A valid Python math expression"}
            },
            "required": ["expression"]
        }
    },
    {
        "name": "web_search",
        "description": "Searches the web for information and returns a summary.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "The search query string"}
            },
            "required": ["query"]
        }
    },
    {
        "name": "write_file",
        "description": "Writes text content to a local file.",
        "input_schema": {
            "type": "object",
            "properties": {
                "filename": {"type": "string", "description": "File name to write"},
                "content": {"type": "string", "description": "Text content to write"}
            },
            "required": ["filename", "content"]
        }
    }
]


def run_calculator(expression: str) -> str:
    try:
        allowed = {k: v for k, v in math.__dict__.items() if not k.startswith("_")}
        allowed["__builtins__"] = {}
        result = eval(expression, allowed)  # noqa: S307
        return str(result)
    except Exception as e:
        return f"Calculator error: {str(e)}"


def run_web_search(query: str) -> str:
    mock_results = {
        "naples florida population": "Naples, Florida has a population of approximately 22,000 within city limits, with the greater Naples metro area reaching over 375,000 residents as of 2024.",
        "average home price naples florida": "The median home price in Naples, Florida is approximately $650,000 as of early 2026, reflecting continued demand in the luxury real estate market.",
        "python anthropic sdk": "The Anthropic Python SDK allows developers to interact with Claude models via the Anthropic API. Install with pip install anthropic.",
    }
    for key, value in mock_results.items():
        if any(word in query.lower() for word in key.split()):
            return value
    return f"Search results for '{query}': No specific data found."


def run_write_file(filename: str, content: str) -> str:
    try:
        with open(filename, "w") as f:
            f.write(content)
        return f"Successfully wrote {len(content)} characters to '{filename}'."
    except Exception as e:
        return f"File write error: {str(e)}"


def execute_tool(tool_name: str, tool_input: dict) -> str:
    if tool_name == "calculator":
        return run_calculator(tool_input["expression"])
    elif tool_name == "web_search":
        return run_web_search(tool_input["query"])
    elif tool_name == "write_file":
        return run_write_file(tool_input["filename"], tool_input["content"])
    else:
        return f"Unknown tool: {tool_name}"


class Agent:
    def __init__(self, system_prompt: str, tools: list):
        self.system_prompt = system_prompt
        self.tools = tools
        self.messages = []
        self.max_iterations = 10

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

    def add_tool_result(self, tool_use_id: str, result: str):
        self.messages.append({
            "role": "user",
            "content": [
                {
                    "type": "tool_result",
                    "tool_use_id": tool_use_id,
                    "content": result
                }
            ]
        })

    def call_claude(self) -> anthropic.types.Message:
        response = client.messages.create(
            model=MODEL,
            max_tokens=4096,
            system=self.system_prompt,
            tools=self.tools,
            messages=self.messages
        )
        return response

    def run(self, task: str) -> str:
        print(f"\n🚀 Starting task: {task}\n{'─' * 60}")
        self.add_user_message(task)
        iterations = 0

        while iterations < self.max_iterations:
            iterations += 1
            print(f"  [Iteration {iterations}] Calling Claude...")
            response = self.call_claude()
            self.messages.append({"role": "assistant", "content": response.content})

            if response.stop_reason == "end_turn":
                final_text = next(
                    (block.text for block in response.content if hasattr(block, "text")),
                    "Task completed."
                )
                print(f"\n✅ Agent finished after {iterations} iteration(s).")
                return final_text

            if response.stop_reason == "tool_use":
                tool_calls = [block for block in response.content if block.type == "tool_use"]
                for tool_call in tool_calls:
                    print(f"  🔧 Tool call: {tool_call.name}({json.dumps(