If you've searched for how to build AI agents tutorial, you've probably hit one of two walls: toy examples that don't actually run, or enterprise docs so dense they assume you already know the answer. This guide skips both. You'll build a real, working AI agent using the Anthropic Claude API and Python — one that can reason, call tools, and handle multi-step tasks without you babysitting every decision.
I've built AI agents for businesses across Southwest Florida, and the pattern is always the same: once someone sees a real agent loop running, everything clicks. That's exactly what I'm going to give you here.
What You'll Build
You'll build a Python-based AI agent that uses Claude claude-sonnet-4-6 to answer questions, execute tool calls, and complete multi-step tasks autonomously. The agent will have access to a web search tool, a calculator tool, and a file-writing tool — and it will decide on its own which tools to use and in what order.
By the end, you'll have a fully working agent loop you can extend with your own tools and deploy in a real project. This is the same architectural foundation we use when building custom AI solutions for clients.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic Python knowledge — you should be comfortable with functions and dictionaries
- The
anthropicPython SDK installed (pip install anthropic) - Optional but helpful: familiarity with JSON structure
The complete, runnable source code is built step-by-step throughout this tutorial. Every snippet below is part of the same agent — by Step 6, you'll have the entire working file. Copy each section in order, or jump to Step 3 for the full assembled version.
Full Source Code
The complete working example is assembled across the steps below. Each section adds a real piece — tool definitions, the agent loop, tool execution, and the main runner. If you want to skip ahead and read the whole thing at once, jump to Step 3. Otherwise, follow the steps in order and it'll make a lot more sense.
Step 1: Understanding Agent Architecture and Tool Use
Before writing a line of code, let's talk about what makes an AI agent different from a regular chatbot. A chatbot responds to a message and stops. An agent responds, decides if it needs more information, calls a tool to get it, reads the result, and keeps going until the task is actually done.
The core loop looks like this: you send a message to Claude, Claude either responds with text or requests a tool call, you execute that tool call and send the result back, and then Claude continues. This repeats until Claude gives you a final text response with no more tool calls. That cycle is the agent loop.
Claude's tool use works through structured JSON schemas. You define what tools exist, what they do, and what parameters they accept. Claude reads those definitions and decides when and how to use them — you never hardcode which tool runs when. This is what makes the agent actually autonomous.
Step 2: Setting Up Claude SDK and Authentication
Let's start with the setup. Install the Anthropic SDK if you haven't already, then configure your API key. I recommend storing the key in an environment variable rather than hardcoding it — you'll thank yourself later when you share the code.
setup.shpip install anthropic export ANTHROPIC_API_KEY="your-api-key-here"
Now let's verify the SDK is working with a minimal initialization. This is the foundation everything else builds on.
agent_init.pyimport anthropic
import os
# Initialize the Anthropic client — it reads ANTHROPIC_API_KEY from environment automatically
client = anthropic.Anthropic()
# Quick connectivity test before we build the full agent
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=256,
messages=[
{"role": "user", "content": "Say 'Agent initialized successfully' and nothing else."}
]
)
print(response.content[0].text)
Run this and you should see exactly: Agent initialized successfully. If you get an authentication error, double-check that your environment variable is exported in the same terminal session where you're running Python.
Agent initialized successfully
Step 3: Defining Tools Your Agent Can Execute
Tool definitions are where most tutorials cut corners. The description field isn't decoration — Claude uses it to decide when to call the tool. Write it like you're explaining the tool to a smart colleague who can't see your code.
Here's the full source file with all three tools defined. This is the complete working agent — everything from tool schemas to the agent loop to the runner. Read through it once, then I'll break each piece down in the steps that follow.
agent.pyimport anthropic
import json
import math
import os
from datetime import datetime
# Initialize client — API key pulled from ANTHROPIC_API_KEY environment variable
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
MAX_ITERATIONS = 10 # Safety limit to prevent infinite loops
# ─────────────────────────────────────────────
# TOOL DEFINITIONS
# ─────────────────────────────────────────────
tools = [
{
"name": "web_search",
"description": (
"Searches the web for current information on a given query. "
"Use this when the user asks about recent events, facts you're unsure about, "
"or anything that requires up-to-date information. Returns a simulated search result."
),
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to look up. Be specific for better results."
}
},
"required": ["query"]
}
},
{
"name": "calculator",
"description": (
"Evaluates a mathematical expression and returns the result. "
"Use this for any arithmetic, algebra, or numerical computation. "
"Supports standard Python math expressions including math module functions."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": (
"A valid Python math expression. Examples: '2 + 2', "
"'math.sqrt(144)', '(15 * 8) / 3'. Do not include variable assignments."
)
}
},
"required": ["expression"]
}
},
{
"name": "write_file",
"description": (
"Writes text content to a file on the local filesystem. "
"Use this when the user asks to save, export, or create a file with specific content. "
"Returns confirmation with the file path and character count."
),
"input_schema": {
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "The filename including extension, e.g. 'report.txt' or 'output.md'."
},
"content": {
"type": "string",
"description": "The full text content to write into the file."
}
},
"required": ["filename", "content"]
}
}
]
# ─────────────────────────────────────────────
# TOOL EXECUTION FUNCTIONS
# ─────────────────────────────────────────────
def run_web_search(query: str) -> str:
"""
Simulates a web search result. In production, replace this with
a real search API call (SerpAPI, Brave Search, Tavily, etc.).
"""
simulated_results = {
"Naples Florida weather": (
"Current weather in Naples, FL: 88°F, partly cloudy, humidity 74%. "
"Afternoon thunderstorms possible. 7-day forecast shows continued warm temperatures."
),
"Anthropic Claude API pricing": (
"Claude claude-sonnet-4-6 pricing as of 2026: input tokens $3/MTok, output tokens $15/MTok. "
"Batch API offers 50% discount. Check console.anthropic.com for current rates."
),
}
# Return a matching result or a generic simulated response
for key, value in simulated_results.items():
if key.lower() in query.lower():
return value
return (
f"Search results for '{query}': Found 10 relevant results. "
f"Top result: According to multiple sources, this topic has been widely covered "
f"as of {datetime.now().strftime('%B %Y')}. "
f"Key finding: The information requested is available and current."
)
def run_calculator(expression: str) -> str:
"""Safely evaluates a math expression using Python's math module."""
try:
# Restrict available names to math functions only — never use bare eval on user input
allowed_names = {k: v for k, v in math.__dict__.items() if not k.startswith("_")}
allowed_names["abs"] = abs
allowed_names["round"] = round
result = eval(expression, {"__builtins__": {}}, allowed_names)
return f"Result: {result}"
except Exception as e:
return f"Calculator error: {str(e)}. Please check the expression syntax."
def run_write_file(filename: str, content: str) -> str:
"""Writes content to a file and returns a status message."""
try:
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
return f"File '{filename}' written successfully. {len(content)} characters saved."
except Exception as e:
return f"File write error: {str(e)}"
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Routes a tool call to the correct execution function."""
if tool_name == "web_search":
return run_web_search(tool_input["query"])
elif tool_name == "calculator":
return run_calculator(tool_input["expression"])
elif tool_name == "write_file":
return run_write_file(tool_input["filename"], tool_input["content"])
else:
return f"Unknown tool: {tool_name}"
# ─────────────────────────────────────────────
# AGENT LOOP
# ─────────────────────────────────────────────
def run_agent(user_message: str) -> str:
"""
Main agent loop. Sends the user message to Claude, handles tool calls,
and continues until Claude returns a final text response.
"""
print(f"\n{'='*60}")
print(f"USER: {user_message}")
print(f"{'='*60}\n")
messages = [{"role": "user", "content": user_message}]
iteration = 0
while iteration < MAX_ITERATIONS:
iteration += 1
print(f"[Iteration {iteration}] Sending request to Claude...")
# Call Claude with the current message history and tool definitions
response = client.messages.create(
model=MODEL,
max_tokens=4096,
tools=tools,
messages=messages
)
print(f"[Iteration {iteration}] 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
print(f"\nAGENT RESPONSE:\n{final_text}")
return final_text
# If Claude wants to use a tool, process all tool calls in this response
if response.stop_reason == "tool_use":
# Add Claude's response (including tool use blocks) to message history
messages.append({"role": "assistant", "content": response.content})
# Collect results for all tool calls in this single response
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f"[Tool Call] {block.name}({json.dumps(block.input)})")
result = execute_tool(block.name, block.input)
print(f"[Tool Result] {result}\n")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Send all tool results back to Claude in a single user message
messages.append({"role": "user", "content": tool_results})
continue
# Safety fallback for unexpected stop reasons
print(f"[Warning] Unexpected stop reason: {response.stop_reason}")
break
return "Agent reached maximum iterations without a final response."
# ─────────────────────────────────────────────
# MAIN — TEST SCENARIOS
# ─────────────────────────────────────────────
if __name__ == "__main__":
# Scenario 1: Multi-step task using calculator and file writer
run_agent(
"Calculate the monthly payment for a $450,000 Naples home with a 7.2% annual interest rate "
"over 30 years, then save the result to a file called 'mortgage_estimate.txt' with a "
"short explanation of the calculation."
)
# Scenario 2: Search and summarize
run_agent(
"Search for the current Claude API pricing and tell me how much it would cost "
"to process 1 million input tokens."
)
Step 4: Implementing the Agent Loop with Streaming
The agent loop in the code above is the heart of everything. Let me walk through exactly what's happening so it's not just lines you copy — it's logic you understand and can modify.
The loop runs until one of two things happens: Claude returns a stop_reason of "end_turn", meaning it's done and has a final answer, or it hits the MAX_ITERATIONS limit as a safety net. Every time Claude asks to use a tool, we add its response to the message history, run the tool, append the result, and loop again.
Here's the isolated loop structure so you can see it clearly:
agent_loop_detail.pyimport anthropic
import json
client = anthropic.Anthropic()
def agent_loop_example(tools: list, messages: list) -> str:
"""
Isolated agent loop showing the core iteration pattern.
In the full agent.py, this logic lives inside run_agent().
"""
MAX_ITERATIONS = 10
for iteration in range(MAX_ITERATIONS):
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
tools=tools,
messages=messages
)
# Terminal condition — Claude finished and has a text answer
if response.stop_reason == "end_turn":
return next(
(block.text for block in response.content if hasattr(block, "text")),
"No text response found."
)
# Tool use condition — Claude wants to call one or more tools
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
results = []
for block in response.content:
if block.type == "tool_use":
# YOUR tool execution logic goes here
tool_output = f"Simulated result for {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": tool_output
})
messages.append({"role": "user", "content": results})
return "Max iterations reached."
user message. Claude can request multiple tools at once, and you need to return all of their results together before it will continue. Sending them one at a time breaks the conversation structure.
Step 5: Handling Tool Calls and Responses
Tool execution is where your agent meets the real world. The three tools in this example — web search, calculator, and file writer — each demonstrate a different class of real-world tool: external API calls, local computation, and filesystem operations.
Notice that the calculator uses a restricted eval environment. I'm deliberately blocking access to builtins so a bad expression can't do something dangerous. This is the kind of thing that bites people in production when they skip it in the tutorial version.
Here's the tool executor in isolation so you can see exactly how to add your own tools:
tool_handler.pyimport anthropic
import json
import math
client = anthropic.Anthropic()
# Minimal tool definition to show the schema pattern
sample_tools = [
{
"name": "get_business_hours",
"description": (
"Returns the operating hours for a given Naples-area business by name. "
"Use this when a user asks when a business is open."
),
"input_schema": {
"type": "object",
"properties": {
"business_name": {
"type": "string",
"description": "The exact name of the business to look up."
},
"day_of_week": {
"type": "string",
"description": "Optional: specific day to check, e.g. 'Monday'. Omit for full week.",
"enum": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
}
},
"required": ["business_name"]
}
}
]
def handle_tool_call(tool_name: str, tool_input: dict) -> str:
"""
Demonstrates how to route tool calls.
Add elif branches here as you add more tools to your agent.
"""
if tool_name == "get_business_hours":
name = tool_input["business_name"]
day = tool_input.get("day_of_week", "all days")
# In production: query your database or external API here
return f"{name} is open 9am–6pm on {day} (simulated result)."
else:
return f"Tool '{tool_name}' not recognized."
def demonstrate_tool_response_structure():
"""Shows exactly what a tool result message looks like in the conversation."""
# This is the structure Claude expects when you return tool results
tool_result_message = {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01XyZ", # Must match the id from Claude's tool_use block
"content": "The tool output goes here as a plain string."
}
]
}
print("Tool result message structure:")
print(json.dumps(tool_result_message, indent=2))
if __name__ == "__main__":
demonstrate_tool_response_structure()
The tool_use_id field is critical — it has to match the id that Claude assigned to its tool call. If these don't match, the API will throw a validation error. In the full agent, we grab this directly from block.id so it's always correct.
Step 6: Testing Your Agent with Real Scenarios
Now let's actually run it. Save the full agent.py from Step 3 and run it with python agent.py. Here's exactly what you should see for the mortgage calculation scenario:
============================================================
USER: Calculate the monthly payment for a $450,000 Naples home with a 7.2% annual
interest rate over 30 years, then save the result to a file called
'mortgage_estimate.txt' with a short explanation of the calculation.
============================================================
[Iteration 1] Sending request to Claude...
[Iteration 1] Stop reason: tool_use
[Tool Call] calculator({"expression": "(450000 * (0.072/12) * (1 + 0.072/12)**360) / ((1 + 0.072/12)**360 - 1)"})
[Tool Result] Result: 3057.85...
[Iteration 2] Sending request to Claude...
[Iteration 2] Stop reason: tool_use
[Tool Call] write_file({"filename": "mortgage_estimate.txt", "content": "Mortgage Estimate\n=================\nHome Price: $450,000\nInterest Rate: 7.2% annually\nLoan Term: 30 years (360 months)\n\nEstimated Monthly Payment: $3,057.85\n\nThis was calculated using the standard amortization formula:\nM = P[r(1+r)^n]/[(1