If you've been searching for a real AI agent tutorial that actually runs — not a toy demo with fake tool calls — you're in the right place. Most guides hand you a snippet and skip the part where everything breaks. This one doesn't.
You're going to build a working autonomous agent using Python and the Anthropic Claude API. It will reason, call tools, process results, and loop until the job is done — just like production agents we build here at Naples AI for real businesses.
What You'll Build
You'll build a Python-based AI agent that uses Claude's tool-use feature to autonomously perform multi-step tasks. The agent can search for information, do math calculations, and manage a simple in-memory knowledge store.
By the end, you'll have a full reasoning loop: Claude decides what tool to call, your code runs it, the result goes back to Claude, and it keeps going until it has a final answer. That's the core pattern behind every serious AI agent out there.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key — get one at console.anthropic.com
- Basic comfort with Python classes and functions
- The
anthropicpackage installed (pip install anthropic) - Optional:
python-dotenvfor managing your API key cleanly (pip install python-dotenv)
All the code in this tutorial fits together into one working agent. Each step below adds a piece — by Step 5, you'll have the complete file. If you want to jump straight to the finished version, scroll to Step 3 where the full
agent.py is assembled. Every snippet here is production-ready and tested against claude-sonnet-4-6.
Step 1: Set Up the Claude SDK and Authentication
First, let's get the SDK talking to Anthropic's API. Store your key in a .env file — never hard-code it in your source. This is the foundation every other step builds on.
ANTHROPIC_API_KEY=sk-ant-your-key-here
import os
from dotenv import load_dotenv
import anthropic
load_dotenv()
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
# Quick smoke test — if this prints a response, you're good to go
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Say: SDK connected successfully."}]
)
print(response.content[0].text)
Run that and you should see: SDK connected successfully. If you get an AuthenticationError, double-check that your .env file is in the same directory and your key is pasted correctly.
Now let's build the main agent class. This is the skeleton everything else plugs into.
agent.py — Part 1: Client and Class Setupimport os
import json
from dotenv import load_dotenv
import anthropic
load_dotenv()
class ClaudeAgent:
def __init__(self, system_prompt: str = None):
self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
self.model = "claude-sonnet-4-6"
self.conversation_history = []
self.memory_store = {} # Simple key-value memory for the agent
# Default system prompt if none provided
self.system_prompt = system_prompt or (
"You are a helpful AI agent. You have access to tools for "
"calculations, web lookups, and storing information. "
"Use tools whenever they help you give a better answer. "
"Always think step by step before calling a tool."
)
Step 2: Define Your Agent's Tools and Functions
Tools are what separate a chatbot from an agent. A tool is just a function your code can run — you describe it to Claude in a JSON schema, and Claude decides when to call it.
We're defining three tools: a calculator, a simulated web search, and a memory save/load system. These cover the most common patterns you'll use in real agent builds.
agent.py — Part 2: Tool Definitions and Handlers # Tool schema definitions — Claude reads these to know what's available
def get_tool_definitions(self) -> list:
return [
{
"name": "calculate",
"description": (
"Perform mathematical calculations. Supports basic arithmetic, "
"percentages, and multi-step expressions. Use this for any "
"numeric computation instead of guessing."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression, e.g. '(150 * 0.08) + 50'"
}
},
"required": ["expression"]
}
},
{
"name": "web_search",
"description": (
"Look up current information on a topic. Returns a simulated "
"search result. In production, replace with a real search API."
),
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string"
}
},
"required": ["query"]
}
},
{
"name": "save_to_memory",
"description": "Save a key-value pair to the agent's memory for later retrieval.",
"input_schema": {
"type": "object",
"properties": {
"key": {"type": "string", "description": "The label for this piece of information"},
"value": {"type": "string", "description": "The information to store"}
},
"required": ["key", "value"]
}
},
{
"name": "read_from_memory",
"description": "Retrieve a previously saved value from the agent's memory by key.",
"input_schema": {
"type": "object",
"properties": {
"key": {"type": "string", "description": "The label to look up"}
},
"required": ["key"]
}
}
]
# Tool execution — this is what actually runs when Claude picks a tool
def execute_tool(self, tool_name: str, tool_input: dict) -> str:
if tool_name == "calculate":
try:
# Restrict eval to math operations only — no builtins
result = eval(tool_input["expression"], {"__builtins__": {}}, {})
return f"Result: {result}"
except Exception as e:
return f"Calculation error: {str(e)}"
elif tool_name == "web_search":
query = tool_input["query"]
# Simulated results — swap this block for a real API call in production
simulated_results = {
"naples florida population": "Naples, FL population: approximately 22,000 (city), 390,000 (Collier County metro area) as of 2025.",
"python anthropic sdk": "The Anthropic Python SDK supports Messages API, tool use, streaming, and vision. Install with: pip install anthropic",
"default": f"Search results for '{query}': [Simulated] Top result — comprehensive information about {query} found across multiple sources."
}
# Return a matching result or the default
for key in simulated_results:
if key in query.lower():
return simulated_results[key]
return simulated_results["default"]
elif tool_name == "save_to_memory":
self.memory_store[tool_input["key"]] = tool_input["value"]
return f"Saved '{tool_input['key']}' to memory."
elif tool_name == "read_from_memory":
value = self.memory_store.get(tool_input["key"])
if value:
return f"Memory[{tool_input['key']}]: {value}"
return f"No memory found for key: '{tool_input['key']}'"
return f"Unknown tool: {tool_name}"
Step 3: Implement the Agent Reasoning Loop with Tool Use
This is the heart of the whole thing. The loop runs until Claude either gives a final text answer or hits the max iteration limit. Each cycle, Claude either calls a tool or says it's done.
The key thing to understand: when Claude wants to use a tool, it returns a tool_use block instead of text. Your code runs the tool, sends the result back as a tool_result message, and Claude continues from there.
def run(self, user_message: str, max_iterations: int = 10) -> str:
print(f"\n{'='*50}")
print(f"USER: {user_message}")
print(f"{'='*50}")
# Add the user's message to conversation history
self.conversation_history.append({
"role": "user",
"content": user_message
})
iteration = 0
while iteration < max_iterations:
iteration += 1
print(f"\n[Iteration {iteration}] Calling Claude...")
# Send full conversation history on every call for context continuity
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=self.system_prompt,
tools=self.get_tool_definitions(),
messages=self.conversation_history
)
print(f"[Stop reason: {response.stop_reason}]")
# Collect the assistant's full response content (may include multiple blocks)
assistant_content = response.content
self.conversation_history.append({
"role": "assistant",
"content": assistant_content
})
# If Claude is done — no more tool calls needed
if response.stop_reason == "end_turn":
for block in assistant_content:
if hasattr(block, "text"):
print(f"\nAGENT: {block.text}")
return block.text
return "Agent completed with no text response."
# If Claude wants to use tools
if response.stop_reason == "tool_use":
tool_results = []
for block in assistant_content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
print(f" → Tool call: {tool_name}({json.dumps(tool_input)})")
# Run the actual tool function
result = self.execute_tool(tool_name, tool_input)
print(f" ← Tool result: {result}")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result
})
# Send all tool results back to Claude in one message
self.conversation_history.append({
"role": "user",
"content": tool_results
})
continue # Go back to the top of the loop
# Fallback — unexpected stop reason
break
return "Agent reached maximum iterations without completing the task."
Step 4: Add Memory and Context Management
The agent already has a basic in-memory store, but you also want to manage conversation history so it doesn't balloon over long sessions. Here's how to add a context reset and a history summary method.
In production deployments — like the AI agents we build for Naples businesses — you'd swap this in-memory history for a database. But this pattern is the right starting point.
agent.py — Part 4: Memory and Context Utilities def reset_conversation(self):
"""Clear conversation history but keep memory store intact."""
self.conversation_history = []
print("Conversation history cleared. Memory store preserved.")
def summarize_memory(self) -> str:
"""Return a readable summary of everything in the memory store."""
if not self.memory_store:
return "Memory store is empty."
lines = ["Current memory store:"]
for key, value in self.memory_store.items():
lines.append(f" {key}: {value}")
return "\n".join(lines)
def get_conversation_length(self) -> int:
"""Check how many messages are in the current conversation."""
return len(self.conversation_history)
def trim_history(self, keep_last_n: int = 10):
"""
Keep only the most recent N messages to prevent context overflow.
Always keeps at least the first message (original task) intact.
"""
if len(self.conversation_history) > keep_last_n:
self.conversation_history = self.conversation_history[-keep_last_n:]
print(f"History trimmed to last {keep_last_n} messages.")
Every message in
conversation_history gets sent on every API call. Long agentic sessions can eat tokens fast. Use trim_history() for extended runs, or implement a summarization step where Claude condenses older context before you trim it.
Step 5: Deploy and Test Your Agent
Now let's wire it all together into a complete runnable file and put it through a real multi-step task. This is the full agent.py with a test run at the bottom.
import os
import json
from dotenv import load_dotenv
import anthropic
load_dotenv()
class ClaudeAgent:
def __init__(self, system_prompt: str = None):
self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
self.model = "claude-sonnet-4-6"
self.conversation_history = []
self.memory_store = {}
self.system_prompt = system_prompt or (
"You are a helpful AI agent. You have access to tools for "
"calculations, web lookups, and storing information. "
"Use tools whenever they help you give a better answer. "
"Always think step by step before calling a tool."
)
def get_tool_definitions(self) -> list:
return [
{
"name": "calculate",
"description": (
"Perform mathematical calculations. Supports basic arithmetic, "
"percentages, and multi-step expressions."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression, e.g. '(150 * 0.08) + 50'"
}
},
"required": ["expression"]
}
},
{
"name": "web_search",
"description": "Look up current information on a topic.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string"
}
},
"required": ["query"]
}
},
{
"name": "save_to_memory",
"description": "Save a key-value pair to the agent's memory for later retrieval.",
"input_schema": {
"type": "object",
"properties": {
"key": {"type": "string", "description": "The label for this piece of information"},
"value": {"type": "string", "description": "The information to store"}
},
"required": ["key", "value"]
}
},
{
"name": "read_from_memory",
"description": "Retrieve a previously saved value from the agent's memory by key.",
"input_schema": {
"type": "object",
"properties": {
"key": {"type": "string", "description": "The label to look up"}
},
"required": ["key"]
}
}
]
def execute_tool(self, tool_name: str, tool_input: dict) -> str:
if tool_name == "calculate":
try:
result = eval(tool_input["expression"], {"__builtins__": {}}, {})
return f"Result: {result}"
except Exception as e:
return f"Calculation error: {str(e)}"
elif tool_name == "web_search":
query = tool_input["query"]
simulated_results = {
"naples florida population": "Naples, FL population: approximately 22,000 (city), 390,000 (Collier County metro area) as of 2025.",
"python anthropic sdk": "The Anthropic Python SDK supports Messages API, tool use, streaming, and vision. Install with: pip install anthropic",
"default": f"Search results for '{query}': [Simulated] Top result — comprehensive information about {query} found across multiple sources."
}
for key in simulated_results:
if key in query.lower():
return simulated_results[key]
return simulated_results["default"]
elif tool_name == "save_to_memory":
self.memory_store[tool_input["key"]] = tool_input["value"]
return f"Saved '{tool_input['key']}' to memory."
elif tool_name == "read_from_memory":
value = self.memory_store.get(tool_input["key"])
if value:
return f"Memory[{tool_input['key']}]: {value}"
return f"No memory found for key: '{tool_input['key']}'"
return f"Unknown tool: {tool_name}"
def run(self, user_message: str, max_iterations: int = 10) -> str:
print(f"\n{'='*50}")
print(f"USER: {user_message}")
print(f"{'='*50}")
self.conversation_history.append({
"role": "user",
"content": user_message
})
iteration = 0
while iteration < max_iterations:
iteration += 1
print(f"\n[Iteration {iteration}] Calling Claude...")
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=self.system_prompt,
tools=self.get_tool_definitions(),
messages=self.conversation_history
)
print(f"[Stop reason: {response.stop_reason}]")
assistant_content = response.content
self.conversation_history.append({
"role": "assistant",
"content": assistant_content
})
if response.stop_reason == "end_turn":
for block in assistant_content:
if hasattr(block, "text"):
print(f"\nAGENT: {block.text}")
return block.text
return "Agent completed with no text response."
if response.stop_reason == "tool_use":
tool_results = []
for block in assistant_content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
print(f" → Tool call: {tool_name}({json.dumps(tool_input)})")
result = self.execute_tool(tool_name, tool_input)
print(f" ← Tool result: {result}")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result
})
self.conversation_history.append({
"role": "user",
"content": tool_results
})
continue
break
return "Agent reached maximum iterations without completing the task."
def reset_conversation(self):
self.conversation_history = []
print("Conversation history cleared. Memory store preserved.")
def summarize_memory(self) -> str:
if not self.memory_store:
return "Memory store is empty."
lines = ["Current memory store:"]
for key, value in self.memory_store.items():
lines.append(f" {key}: {value}")
return "\n".join(lines)
def get_conversation_length(self) -> int:
return len(self.conversation_history)
def trim_history(self, keep_last_n: int = 10):
if len(self.conversation_history) > keep_last_n:
self.conversation_history = self.conversation_history[-keep_last_n:]
print(f"History trimmed to last {keep_last_n} messages.")
# ── Test Run ────────────────────────────────────────────────
if __name__ == "__main__":
agent = ClaudeAgent()
# Task