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.
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
anthropicpackage installed:pip install anthropic- A terminal and a text editor (VS Code works great)
Full Source Code
python agent.py.
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.
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."
)
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."
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:
#
Naples AI