If you've been searching for a clear, working tutorial on how to build AI agents — not a toy demo, but something that actually calls tools, makes decisions, and loops until it finishes a task — you're in the right place. Most tutorials online either skip the hard parts or give you pseudocode that doesn't run. This one gives you everything: real Python, real Anthropic SDK calls, and a complete agentic loop you can run today.
By the end of this guide, you'll have a working autonomous agent built on Claude's API that can use multiple tools, handle errors gracefully, and stop itself when the job is done. Let's build it.
What You'll Build
You'll build a Python-based autonomous AI agent powered by Claude that can use three tools: a web search simulator, a calculator, and a database query function. The agent runs a loop, decides which tool to call, processes the result, and keeps going until it has a complete answer — all without you touching it mid-run.
This is the same foundational pattern used in production AI systems for things like real estate listing automation, intelligent process automation, and customer-facing AI assistants. It's a real architecture, not a tutorial toy.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python functions and classes
- The
anthropicPython package installed (pip install anthropic) - A terminal / command prompt you're comfortable using
agent.py file.
Step 1: Set Up Your Python Environment and Claude API Key
Start by creating a clean project folder and installing the Anthropic SDK. I always use a virtual environment so nothing bleeds into other projects on my machine.
terminalmkdir claude-agent cd claude-agent python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate pip install anthropic python-dotenv
Next, create a .env file in your project root to store your API key. Never hard-code credentials in source files — it's a habit that'll save you grief later.
ANTHROPIC_API_KEY=your_api_key_here
Now verify your setup works with a quick test before writing the agent. If this doesn't return a message, fix it before moving on.
test_connection.pyimport anthropic
import os
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=256,
messages=[{"role": "user", "content": "Say hello in one sentence."}]
)
print(message.content[0].text)Expected output:
outputHello! It's great to meet you — how can I help you today?
AuthenticationError, double-check that your .env file is in the same directory you're running the script from, and that the key doesn't have extra spaces or quotes around it.
Step 2: Create the Core Agent Class with Tool Definitions
This is where the real work starts. The agent class holds your Claude client, your tool definitions, and the conversation history. Tool definitions tell Claude what capabilities it has — think of them as a menu Claude reads before deciding what to do next.
Claude's tool-calling format uses JSON Schema to describe each tool's input parameters. Get this right and the model knows exactly how to call your functions.
agent.pyimport anthropic
import os
import json
import math
from dotenv import load_dotenv
load_dotenv()
# Tool definitions tell Claude what tools are available and how to call them
TOOLS = [
{
"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 already know."
),
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string"
}
},
"required": ["query"]
}
},
{
"name": "calculator",
"description": (
"Evaluate a mathematical expression and return the numeric result. "
"Supports standard arithmetic, exponents, and math module functions."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression, e.g. '2 ** 10' or 'math.sqrt(144)'"
}
},
"required": ["expression"]
}
},
{
"name": "database_query",
"description": (
"Query an internal business database for records such as property listings, "
"customer data, or inventory. Returns matching rows as a list of dicts."
),
"input_schema": {
"type": "object",
"properties": {
"table": {
"type": "string",
"description": "The table name to query (e.g. 'listings', 'customers')"
},
"filter_field": {
"type": "string",
"description": "The field name to filter on"
},
"filter_value": {
"type": "string",
"description": "The value to match in the filter field"
}
},
"required": ["table", "filter_field", "filter_value"]
}
}
]
class ClaudeAgent:
def __init__(self):
self.client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
self.model = "claude-sonnet-4-5"
self.tools = TOOLS
self.conversation_history = []
self.max_iterations = 10 # Safety cap to prevent infinite loops
def add_user_message(self, content: str):
"""Append a user turn to the conversation history."""
self.conversation_history.append({"role": "user", "content": content})
def add_assistant_message(self, content):
"""Append an assistant turn (raw content blocks) to conversation history."""
self.conversation_history.append({"role": "assistant", "content": content})
def add_tool_result(self, tool_use_id: str, result: str):
"""
After Claude calls a tool, we return the result as a user message.
This is the required format for tool result turns in the Anthropic API.
"""
self.conversation_history.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result
}
]
})
def call_claude(self):
"""Send current conversation history to Claude and return the response."""
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
tools=self.tools,
messages=self.conversation_history
)
return responseStep 3: Implement the Tool-Calling Loop
The agentic loop is the engine of the whole thing. It sends messages to Claude, checks whether Claude wants to call a tool or is done, executes the tool if needed, feeds the result back, and repeats. The loop stops when Claude returns end_turn or when we hit our iteration cap.
This pattern — send, check, execute, repeat — is the core of virtually every autonomous AI agent in production today. Getting the stop conditions right is what separates a working agent from one that runs forever or halts too early.
agent.py (continued — add these methods to ClaudeAgent) def execute_tool(self, tool_name: str, tool_input: dict) -> str:
"""Route a tool call to the correct function and return the result as a string."""
if tool_name == "web_search":
return web_search(tool_input["query"])
elif tool_name == "calculator":
return calculator(tool_input["expression"])
elif tool_name == "database_query":
return database_query(
tool_input["table"],
tool_input["filter_field"],
tool_input["filter_value"]
)
else:
return f"Error: Unknown tool '{tool_name}'"
def run(self, user_query: str) -> str:
"""
Main agentic loop. Accepts a user query, runs until Claude signals end_turn
or max_iterations is reached, and returns the final text response.
"""
print(f"\n{'='*60}")
print(f"USER: {user_query}")
print(f"{'='*60}")
self.add_user_message(user_query)
iterations = 0
while iterations < self.max_iterations:
iterations += 1
print(f"\n[Iteration {iterations}] Calling Claude...")
response = self.call_claude()
# Save assistant's response to history (content is a list of blocks)
self.add_assistant_message(response.content)
# Check if Claude is done — stop_reason "end_turn" means no more tool calls
if response.stop_reason == "end_turn":
# Extract the plain text from the final response
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
print(f"\n{'='*60}")
print(f"AGENT FINAL ANSWER:\n{final_text}")
print(f"{'='*60}\n")
return final_text
# Claude wants to call one or more tools
if response.stop_reason == "tool_use":
for block in response.content:
# Only process tool_use blocks; skip text blocks in this pass
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
print(f" → Tool called: {tool_name}")
print(f" → Input: {json.dumps(tool_input, indent=4)}")
result = self.execute_tool(tool_name, tool_input)
print(f" → Result: {result}")
# Feed tool result back into the conversation
self.add_tool_result(tool_use_id, result)
# After processing all tool calls, loop again to get Claude's next response
continue
# Fallback: unexpected stop reason
print(f"[Warning] Unexpected stop_reason: {response.stop_reason}")
break
return "Agent reached maximum iterations without completing the task."Step 4: Build the Example Tools
These are the actual functions the agent calls. In a production system, web_search would call a real search API like Brave or SerpAPI, and database_query would hit a real database. For this tutorial, they return realistic simulated data so you can run everything without extra API keys.
The key thing to notice: every tool returns a plain string. That's what the Anthropic API expects as a tool result — just text Claude can read and reason about.
agent.py (continued — add these as module-level functions)def web_search(query: str) -> str:
"""
Simulated web search. Replace the body of this function with a real
search API call (e.g., Brave Search, SerpAPI) for production use.
"""
simulated_results = {
"naples florida real estate median price 2026": (
"According to recent market data, the median home price in Naples, FL "
"as of mid-2026 is $685,000, up 4.2% year-over-year. Inventory remains "
"tight at 2.1 months of supply, keeping the market competitive."
),
"anthropic claude api rate limits": (
"Claude API rate limits vary by tier. The default tier allows 50 requests "
"per minute and 100,000 tokens per minute. Enterprise plans have higher limits. "
"See the Anthropic docs at docs.anthropic.com for current numbers."
),
"python virtual environment setup": (
"To create a Python virtual environment: run 'python -m venv venv', "
"then activate it with 'source venv/bin/activate' on Mac/Linux or "
"'venv\\Scripts\\activate' on Windows."
)
}
# Return a matching result or a generic fallback
for key, value in simulated_results.items():
if any(word in query.lower() for word in key.split()):
return value
return (
f"Search results for '{query}': No cached results found in simulation mode. "
"In production, this would return live web results from your search API."
)
def calculator(expression: str) -> str:
"""
Safely evaluate a math expression. Uses a restricted namespace to prevent
arbitrary code execution — never use raw eval() in production without this.
"""
try:
# Allow only math functions and basic builtins — no imports, no exec
allowed_names = {name: getattr(math, name) for name in dir(math)}
allowed_names["__builtins__"] = {}
result = eval(expression, {"__builtins__": {}}, allowed_names) # noqa: S307
return str(result)
except Exception as e:
return f"Calculator error: {str(e)}"
def database_query(table: str, filter_field: str, filter_value: str) -> str:
"""
Simulated database query. In production, replace this with SQLAlchemy,
a direct DB connection, or an API call to your data layer.
"""
# Simulated in-memory dataset
mock_database = {
"listings": [
{"id": 1, "city": "Naples", "price": 725000, "beds": 3, "status": "active"},
{"id": 2, "city": "Naples", "price": 1200000, "beds": 4, "status": "active"},
{"id": 3, "city": "Bonita Springs", "price": 540000, "beds": 3, "status": "pending"},
{"id": 4, "city": "Marco Island", "price": 980000, "beds": 4, "status": "active"},
],
"customers": [
{"id": 1, "name": "Sandra Torres", "tier": "premium", "city": "Naples"},
{"id": 2, "name": "Mike Deluca", "tier": "standard", "city": "Fort Myers"},
]
}
if table not in mock_database:
return f"Error: Table '{table}' not found."
rows = mock_database[table]
# Filter rows where filter_field matches filter_value
matches = [
row for row in rows
if str(row.get(filter_field, "")).lower() == filter_value.lower()
]
if not matches:
return f"No records found in '{table}' where {filter_field} = '{filter_value}'."
return json.dumps(matches, indent=2)Step 5: Run Your Agent and Test with Real Queries
Now we wire everything together with a main block and run it. I'm testing three different queries so you can see the agent handle web search, calculation, and database lookup — sometimes chaining them together in one run.
agent.py (continued — add this at the bottom of the file)if __name__ == "__main__":
agent = ClaudeAgent()
# Test 1: Triggers web_search tool
agent.run(
"What is the current median home price in Naples, Florida, "
"and how does that compare to a $500,000 budget if I need to put 20% down?"
)
# Reset history between independent queries
agent.conversation_history = []
# Test 2: Triggers calculator tool
agent.run(
"If a property generates $8,500/month in rent and my mortgage payment is "
"$4,200/month, what is my annual cash flow? Also show me the cash-on-cash "
"return if my total investment was $145,000."
)
# Reset history
agent.conversation_history = []
# Test 3: Triggers database_query tool
agent.run(
"Show me all active listings in Naples from our database."
)Here's what running the first query actually looks like in your terminal:
sample output============================================================
USER: What is the current median home price in Naples, Florida,
and how does that compare to a $500,000 budget if I need to put 20% down?
============================================================
[Iteration 1] Calling Claude...
→ Tool called: web_search
→ Input: {
"query": "naples florida real estate median price 2026"
}
→ Result: According to recent market data, the median home price in Naples, FL
as of mid-2026 is $685,000, up 4.2% year-over-year. Inventory remains
tight at 2.1 months of supply, keeping the market competitive.
[Iteration 2] Calling Claude...
→ Tool called: calculator
→ Input: {
"expression": "500000 * 0.20"
}
→ Result: 100000.0
[Iteration 3] Calling Claude...
============================================================
AGENT FINAL ANSWER:
Here's the breakdown based on current Naples market data:
**Naples Median Home Price (2026):** $685,000
**Your Budget:** $500,000
**Gap:** $185,000 below median
With a 20% down payment on a $500,000 home, you'd need **$100,000 upfront**.
This would put you in the lower tier of the Naples market — you'd likely be
looking at condos, townhomes, or properties in the outskirts of Naples proper
rather than single-family homes near the beach. Bonita Springs and Estero
may offer more options in your price range.
============================================================How It Works
Here's the plain-English version of what's happening under the hood. When you call agent.run(), your message goes into a conversation list and gets sent to Claude. Claude reads your message and its tool definitions — that menu of capabilities we set up — and decides whether it needs more information or can answer directly.
If Claude needs a tool, it returns a response with stop_reason: "tool_use" and a tool_use block containing the tool name and arguments it wants to pass. Your loop catches that, runs the actual Python function, and sends the result back as a tool_result message. Then Claude gets another turn.
This continues until Claude has enough information to write a final answer, at which point it returns stop_reason: "end_turn" with plain text. Your loop extracts that text and returns it. The max_iterations cap is a safety net so a confused model can't run up your API bill indefinitely.
Common Errors and Fixes
Error 1: Invalid API Key
anthropic.AuthenticationError: Error code: 401 - {'type': 'error',
'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}Fix: Your .env file isn't loading or the key is wrong. Make sure load_dotenv() is called before os.getenv(), the .env file is in the same directory as your script, and there are no quotes around the key value in the file. Print os.getenv("ANTHROPIC_API_KEY") to confirm it's reading correctly.
Error 2: Tool Result Sent in Wrong Message Format
anthropic.BadRequestError: Error code: 400 - {'type': 'error',
'error': {'type': 'invalid_request_error',
'message': 'tool_result blocks can only be placed in a user message'}}Fix: You must return tool results as a user role message with a tool_result