All articles

If you've been searching for a real, working how to build AI agents tutorial — not a toy demo, not a "coming soon" placeholder — you're in the right place. Most tutorials stop at the API call. This one builds something you can actually deploy.

By the end of this guide, you'll have a production-ready AI agent running on Python and the Anthropic SDK. It can reason, call tools, handle errors, and loop until it finishes a job. That's the foundation every real-world agent is built on.

1. What You'll Build

You'll build a fully functional AI agent that uses Claude's tool-use feature to complete multi-step tasks autonomously. The agent can search for information, perform calculations, and fetch data — all from a simple Python class you control.

The final result is a reusable ClaudeAgent class with a streaming agent loop, structured tool definitions, error handling, and logging built in. It's the same architecture we use at Naples AI when building custom agent systems for local businesses.

📦 Full Source Code
The complete, working code is broken into steps below so you understand each piece. By Step 8, you'll have the entire file assembled and ready to run. Copy each snippet in order, or grab the full assembled version from Step 3.

2. Prerequisites

  • Python 3.10 or higher installed
  • An Anthropic API key (get one at console.anthropic.com)
  • Basic familiarity with Python classes and functions
  • A terminal / command prompt you're comfortable using
  • pip available to install packages

You don't need prior AI experience. If you can write a Python function and run a script, you can follow this tutorial.

3. Full Source Code

Here's the complete assembled file. The steps below explain every section in detail. I recommend reading through the steps first, then coming back here as a reference.

claude_agent_complete.py
import anthropic
import json
import logging
import time
from typing import Any

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

TOOL_DEFINITIONS = [
    {
        "name": "web_search",
        "description": (
            "Search the web for current information on a topic. "
            "Use this when you need facts, news, or data you don't have."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query to look up"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "calculate",
        "description": (
            "Perform a mathematical calculation. "
            "Accepts any valid Python math expression as a string."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A Python math expression, e.g. '(150 * 0.08) + 200'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_current_time",
        "description": "Returns the current date and time in ISO 8601 format.",
        "input_schema": {
            "type": "object",
            "properties": {},
            "required": []
        }
    }
]

SYSTEM_PROMPT = """You are a helpful AI assistant built by Naples AI, 
a custom AI solutions agency in Southwest Florida.

You have access to tools that let you search the web, 
perform calculations, and check the current time. 

Use tools when you need real data. Think step by step. 
When you have enough information, give a clear, concise answer.
Do not make up facts — if you need information, use a tool to get it."""


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """
    Simulates tool execution. In production, replace each branch
    with a real API call, database query, or service integration.
    """
    logger.info(f"Executing tool: {tool_name} with input: {tool_input}")

    if tool_name == "web_search":
        query = tool_input.get("query", "")
        # In production: call a real search API like Brave, Serper, or Google
        return (
            f"Search results for '{query}': "
            f"Naples, Florida is a coastal city in Collier County with a population "
            f"of approximately 22,000. It is known for white sand beaches, luxury real "
            f"estate, and a high median household income exceeding $100,000."
        )

    elif tool_name == "calculate":
        expression = tool_input.get("expression", "")
        try:
            # Only allow safe math operations
            allowed = set("0123456789+-*/()., %")
            if not all(c in allowed for c in expression.replace(" ", "")):
                return "Error: Expression contains invalid characters."
            result = eval(expression)  # Safe because we validated the input above
            return f"Result: {result}"
        except Exception as e:
            return f"Calculation error: {str(e)}"

    elif tool_name == "get_current_time":
        from datetime import datetime, timezone
        now = datetime.now(timezone.utc).isoformat()
        return f"Current UTC time: {now}"

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


class ClaudeAgent:
    def __init__(
        self,
        api_key: str,
        model: str = "claude-sonnet-4-6",
        max_tokens: int = 4096,
        max_iterations: int = 10
    ):
        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model
        self.max_tokens = max_tokens
        self.max_iterations = max_iterations
        self.tools = TOOL_DEFINITIONS
        self.system_prompt = SYSTEM_PROMPT
        logger.info(f"ClaudeAgent initialized with model: {self.model}")

    def run(self, user_message: str) -> str:
        """
        Main agent loop. Sends messages to Claude, handles tool calls,
        and keeps going until Claude gives a final answer or we hit
        the max iteration limit.
        """
        logger.info(f"Agent starting task: {user_message[:80]}...")

        messages = [{"role": "user", "content": user_message}]
        iteration = 0

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

            try:
                response = self.client.messages.create(
                    model=self.model,
                    max_tokens=self.max_tokens,
                    system=self.system_prompt,
                    tools=self.tools,
                    messages=messages
                )
            except anthropic.APIConnectionError as e:
                logger.error(f"Connection error: {e}")
                raise
            except anthropic.RateLimitError as e:
                logger.warning(f"Rate limit hit. Waiting 60s. Error: {e}")
                time.sleep(60)
                continue
            except anthropic.APIStatusError as e:
                logger.error(f"API error {e.status_code}: {e.message}")
                raise

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

            # If Claude is done, extract the final text and 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 completed task.")
                return final_text.strip()

            # If Claude wants to use tools, process each tool call
            if response.stop_reason == "tool_use":
                # Add Claude's response (including tool_use blocks) to history
                messages.append({
                    "role": "assistant",
                    "content": response.content
                })

                # Build the tool results to send back
                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[:120]}...")
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result
                        })

                # Send tool results back to Claude as a user message
                messages.append({
                    "role": "user",
                    "content": tool_results
                })

            else:
                # Unexpected stop reason — bail out safely
                logger.warning(f"Unexpected stop reason: {response.stop_reason}")
                break

        logger.warning("Agent hit max iterations without a final answer.")
        return "I was unable to complete the task within the allowed number of steps."


def main():
    import os

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

    agent = ClaudeAgent(api_key=api_key)

    tasks = [
        "What is 15% tip on a $87.50 restaurant bill, and what is the total?",
        "Search for information about Naples Florida and summarize it briefly.",
        "What time is it right now, and what day of the week will it be in 10 days?"
    ]

    for task in tasks:
        print(f"\n{'='*60}")
        print(f"TASK: {task}")
        print('='*60)
        result = agent.run(task)
        print(f"ANSWER:\n{result}")
        print()


if __name__ == "__main__":
    main()
        

4. Step 1 — Set Up the Claude API and Python Environment

First, create a project folder and a virtual environment. This keeps your dependencies clean and separate from other projects.

terminal
mkdir claude-agent && cd claude-agent
python -m venv venv
source venv/bin/activate        # On Windows: venv\Scripts\activate
pip install anthropic
        

Next, set your Anthropic API key as an environment variable. Never hardcode keys in your source files.

terminal
# macOS / Linux
export ANTHROPIC_API_KEY="sk-ant-your-key-here"

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

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

Let's verify the connection works before we build anything else. Run this quick check:

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": "Reply with just the word: connected"}]
)

print(message.content[0].text)
# Expected output: connected
        

If you see connected printed in your terminal, you're good to go. If you get an authentication error, double-check that your API key is set correctly in the environment.

5. Step 2 — Create Your First Agent with Tool Use

Tool use (also called function calling) is what separates an AI agent from a simple chatbot. Instead of just generating text, Claude can decide to call a function, wait for the result, and use that result in its next response.

Here's the core ClaudeAgent class initialization. This is where we wire up the Anthropic client and set the defaults:

agent_init.py
import anthropic
import logging

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


class ClaudeAgent:
    def __init__(
        self,
        api_key: str,
        model: str = "claude-sonnet-4-6",
        max_tokens: int = 4096,
        max_iterations: int = 10
    ):
        # Initialize the Anthropic client with your API key
        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model
        self.max_tokens = max_tokens
        # max_iterations prevents infinite loops in the agent loop
        self.max_iterations = max_iterations
        self.tools = []         # We'll add tool definitions in Step 3
        self.system_prompt = "" # We'll add the system prompt in Step 3
        logger.info(f"ClaudeAgent initialized with model: {self.model}")
        
💡 Why max_iterations?
Without a limit, a buggy agent could loop forever, burning through API credits. Ten iterations is generous for most tasks. For complex research agents, you might push it to 20 or 25.

6. Step 3 — Define Tools and System Prompts

Tools are JSON objects that tell Claude what functions it can call and what inputs they expect. Think of it as a menu — Claude reads the menu and orders what it needs.

Here are three practical tools: web search, calculator, and a clock. Each one has a name, a description Claude reads to decide when to use it, and an input schema that defines what parameters to pass.

tool_definitions.py
import anthropic
import json
import logging
from datetime import datetime, timezone

logger = logging.getLogger(__name__)

# Tool definitions tell Claude what capabilities the agent has.
# The description is the most important field — write it clearly.
TOOL_DEFINITIONS = [
    {
        "name": "web_search",
        "description": (
            "Search the web for current information on a topic. "
            "Use this when you need facts, news, or data you don't have."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query to look up"
                }
            },
            "required": ["query"]
        }
    },
    {
        "name": "calculate",
        "description": (
            "Perform a mathematical calculation. "
            "Accepts any valid Python math expression as a string."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "A Python math expression, e.g. '(150 * 0.08) + 200'"
                }
            },
            "required": ["expression"]
        }
    },
    {
        "name": "get_current_time",
        "description": "Returns the current date and time in ISO 8601 format.",
        "input_schema": {
            "type": "object",
            "properties": {},
            "required": []
        }
    }
]

# The system prompt shapes Claude's behavior throughout the session.
# Keep it focused — vague prompts produce vague agents.
SYSTEM_PROMPT = """You are a helpful AI assistant built by Naples AI, 
a custom AI solutions agency in Southwest Florida.

You have access to tools that let you search the web, 
perform calculations, and check the current time. 

Use tools when you need real data. Think step by step. 
When you have enough information, give a clear, concise answer.
Do not make up facts — if you need information, use a tool to get it."""


def execute_tool(tool_name: str, tool_input: dict) -> str:
    """
    Routes a tool call to the right function and returns the result as a string.
    In production, replace these stubs with real API calls.
    """
    logger.info(f"Executing tool: {tool_name} | input: {tool_input}")

    if tool_name == "web_search":
        query = tool_input.get("query", "")
        # Replace this with a real search API call in production
        return (
            f"Search results for '{query}': Naples, Florida is a coastal city "
            f"in Collier County with approximately 22,000 residents. Known for "
            f"white sand beaches, luxury real estate, and a median household "
            f"income exceeding $100,000."
        )

    elif tool_name == "calculate":
        expression = tool_input.get("expression", "")
        allowed = set("0123456789+-*/()., %")
        if not all(c in allowed for c in expression.replace(" ", "")):
            return "Error: Expression contains invalid characters."
        try:
            result = eval(expression)
            return f"Result: {result}"
        except Exception as e:
            return f"Calculation error: {str(e)}"

    elif tool_name == "get_current_time":
        now = datetime.now(timezone.utc).isoformat()
        return f"Current UTC time: {now}"

    return f"Unknown tool: {tool_name}"
        
⚠️ Production Note on web_search
The search tool above returns a static stub. In a real deployment, you'd call an API like Serper, Brave Search, or Tavily. The agent loop doesn't change — just swap out the stub with your real HTTP call inside the web_search branch.

7. Step 4 — Implement the Agent Loop with Message Handling

This is the heart of the whole system. The agent loop sends a message to Claude, checks why it stopped, and decides what to do next — either run a tool and continue, or return the final answer.

The conversation history builds up inside the messages list. That's how Claude remembers what happened in previous iterations.

agent_loop.py
import anthropic
import json
import logging
import time
import os

logger = logging.getLogger(__name__)

# Paste TOOL_DEFINITIONS, SYSTEM_PROMPT, and execute_tool from Step 3 here,
# then add this method inside the ClaudeAgent class:

class ClaudeAgent:
    def __init__(
        self,
        api_key: str,
        model: str = "claude-sonnet-4-6",
        max_tokens: int = 4096,
        max_iterations: int = 10
    ):
        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model
        self.max_tokens = max_tokens
        self.max_iterations = max_iterations
        self.tools = TOOL_DEFINITIONS
        self.system_prompt = SYSTEM_PROMPT

    def run(self, user_message: str) -> str:
        """
        Runs the agent loop until Claude returns a final answer
        or we hit max_iterations.
        """
        logger.info(f"Starting task: {user_message[:80]}...")

        # Start the conversation with the user's message
        messages = [{"role": "user", "content": user_message}]
        iteration = 0

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

            try:
                response = self.client.messages.create(
                    model=self.model,
                    max_tokens=self.max_tokens,
                    system=self.system_prompt,
                    tools=self.tools,
                    messages=messages
                )
            except anthropic.RateLimitError:
                logger.warning("Rate limited. Waiting 60 seconds...")
                time.sleep(60)
                continue
            except anthropic.APIConnectionError as e:
                logger.error(f"Connection failed: {e}")
                raise

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

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

            # "tool_use" means Claude wants to call a function
            if response.stop_reason == "tool_use":
                # Append Claude's full response (including tool_use blocks) to history
                messages.append({
                    "role": "assistant",
                    "content": response.content
                })

                # Execute each tool Claude requested and collect results
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        logger.info(f"Claude requested tool: {block.name}")
                        result = execute_tool(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,  # Must match the tool_use block's id