If you've been searching for a solid how to build AI agents tutorial that actually runs without errors, you're in the right place. Most guides either hand you pseudocode or skip the parts that trip people up, like handling tool calls inside a streaming loop. This tutorial walks you through building a real, working autonomous agent with the Claude API from scratch, and you'll be done in about 30 minutes.
What You'll Build
You'll build a Python-based AI agent that uses Claude's tool-use capability to answer questions by calling real functions, not just generating text. The agent runs an agentic loop, meaning it keeps working through a problem, calling tools and processing results, until it has a final answer to give back to you. By the end, you'll have a working agent class you can drop into any project and extend with your own tools.
Prerequisites
- Python 3.10 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic comfort with Python classes and functions
- The
anthropicPython SDK installed (pip install anthropic) - A terminal and a code editor
Full Source Code
The steps below build every part of this agent incrementally. Each code block is a real, runnable piece of the final file. By the end of Step 4, you'll have the complete agent assembled and ready to test in Step 5.
Step 1: Setting Up Your Claude API Environment
First things first, let's get your environment wired up correctly. A lot of agent bugs come from misconfigured clients or missing environment variables, so let's be deliberate here.
Create a file called .env in your project root and add your API key. Then install the packages you need.
pip install anthropic python-dotenv
Now create your project file and set up the client. This is the initialization block that everything else builds on.
agent.pyimport os
import json
import anthropic
from dotenv import load_dotenv
# Load your API key from the .env file
load_dotenv()
# Initialize the Anthropic client
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# Model config — we're using claude-sonnet-4-6 throughout this tutorial
MODEL = "claude-sonnet-4-6"
MAX_TOKENS = 4096
Your .env file should look like this. Keep it out of version control by adding it to .gitignore.
ANTHROPIC_API_KEY=sk-ant-your-key-here
Step 2: Creating Your First Agent Class with Tool Use
Now let's build the agent class itself. The class holds your conversation history, system prompt, and the tools the agent can call. Keeping state inside a class makes it easy to run multi-turn conversations without passing context around manually.
Here's the agent initialization. Add this to your agent.py file right after the setup block above.
class Agent:
def __init__(self, system_prompt: str, tools: list[dict]):
"""
Initialize the agent with a system prompt and a list of tool definitions.
Args:
system_prompt: Instructions that define the agent's behavior and persona.
tools: A list of tool schema dicts that Claude can call during a run.
"""
self.client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
self.model = MODEL
self.max_tokens = MAX_TOKENS
self.system_prompt = system_prompt
self.tools = tools
# Conversation history persists across turns
self.messages: list[dict] = []
def reset(self):
"""Clear conversation history to start a fresh session."""
self.messages = []
The self.messages list is where the magic happens. Every user message, assistant response, and tool result gets appended here, which is what gives the agent memory across multiple turns. Without that running history, Claude would forget everything between calls.
Step 3: Defining Tools and Tool Schemas
Tools are how your agent interacts with the real world. Claude doesn't execute code itself, it tells you which function to call and with what arguments, and then you run that function and pass the result back. The schema you define here is what Claude reads to understand what each tool does and what inputs it expects.
Let's define three practical tools: a calculator, a weather lookup, and a simple database query simulator. These cover the patterns you'll use in real projects.
agent.py (continued)# Tool schemas — these tell Claude what tools are available and how to call them
TOOLS = [
{
"name": "calculate",
"description": (
"Performs basic arithmetic calculations. Use this when the user asks "
"for math operations like addition, subtraction, multiplication, or division."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression, e.g. '(15 * 4) / 2 + 10'"
}
},
"required": ["expression"]
}
},
{
"name": "get_weather",
"description": (
"Returns simulated current weather data for a given city. "
"Use this when the user asks about weather conditions."
),
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The name of the city to get weather for, e.g. 'Naples, FL'"
}
},
"required": ["city"]
}
},
{
"name": "query_database",
"description": (
"Looks up a business record by name from a mock database. "
"Use this when the user asks about specific business information."
),
"input_schema": {
"type": "object",
"properties": {
"business_name": {
"type": "string",
"description": "The exact or partial name of the business to look up."
}
},
"required": ["business_name"]
}
}
]
Now write the actual Python functions those tools map to. These are the real functions your code will call when Claude requests a tool.
agent.py (continued)def calculate(expression: str) -> str:
"""Safely evaluate a math expression and return the result as a string."""
try:
# Using eval with a restricted namespace for basic safety
result = eval(expression, {"__builtins__": {}}, {})
return f"Result: {result}"
except Exception as e:
return f"Calculation error: {str(e)}"
def get_weather(city: str) -> str:
"""Return simulated weather data for a city."""
# In a real agent, you'd call a weather API like OpenWeatherMap here
mock_data = {
"naples": {"temp": 82, "condition": "Sunny", "humidity": "68%"},
"miami": {"temp": 86, "condition": "Partly Cloudy", "humidity": "74%"},
"orlando": {"temp": 79, "condition": "Thunderstorms", "humidity": "85%"},
}
city_key = city.lower().split(",")[0].strip()
data = mock_data.get(city_key, {"temp": 75, "condition": "Clear", "humidity": "60%"})
return (
f"Weather in {city}: {data['condition']}, "
f"{data['temp']}°F, Humidity: {data['humidity']}"
)
def query_database(business_name: str) -> str:
"""Simulate a database lookup for a business record."""
# In production, this would query a real database
mock_db = {
"naples ai": {
"founded": "2024",
"location": "Naples, FL",
"services": "Custom AI development, process automation, AI chatbots",
"founder": "Chris Mejias"
},
"naples bakery": {
"founded": "2019",
"location": "Naples, FL",
"services": "Artisan breads, pastries, catering"
}
}
key = business_name.lower()
for db_key, record in mock_db.items():
if key in db_key or db_key in key:
return json.dumps(record, indent=2)
return f"No record found for '{business_name}'."
# Map tool names to their Python functions for easy dispatch
TOOL_FUNCTIONS = {
"calculate": calculate,
"get_weather": get_weather,
"query_database": query_database,
}
Step 4: Building the Agent Run Loop with Streaming
This is the core of autonomous AI agent development. The agentic loop runs until Claude either gives a final text response or hits the max iterations you set. Each iteration sends the current message history, gets a response, checks whether Claude wants to call a tool, executes that tool, and feeds the result back in. Repeat until done.
Add the run method to your Agent class. This is the full agentic loop with streaming.
def run(self, user_message: str, max_iterations: int = 10) -> str:
"""
Run the agent loop for a given user message.
The loop continues until Claude returns a final text response
or we hit the max_iterations limit.
Args:
user_message: The input from the user to process.
max_iterations: Safety limit to prevent infinite loops.
Returns:
The final text response from the agent.
"""
# Append the new user message to the running history
self.messages.append({"role": "user", "content": user_message})
iteration = 0
final_response = ""
while iteration < max_iterations:
iteration += 1
print(f"\n[Agent Loop] Iteration {iteration}")
# Collect the full streamed response
response_text = ""
tool_calls = []
stop_reason = None
# Use streaming to get the response from Claude
with self.client.messages.stream(
model=self.model,
max_tokens=self.max_tokens,
system=self.system_prompt,
tools=self.tools,
messages=self.messages,
) as stream:
for event in stream:
# Handle text delta events for streaming output
if event.type == "content_block_delta":
if hasattr(event.delta, "text"):
response_text += event.delta.text
print(event.delta.text, end="", flush=True)
# Get the final message object after stream completes
final_message = stream.get_final_message()
stop_reason = final_message.stop_reason
# Extract any tool use blocks from the response content
for block in final_message.content:
if block.type == "tool_use":
tool_calls.append(block)
# Append Claude's full response to the message history
self.messages.append({
"role": "assistant",
"content": final_message.content
})
# If Claude is done talking (no tool calls needed), return the response
if stop_reason == "end_turn" and not tool_calls:
final_response = response_text
break
# If Claude wants to use tools, execute them and feed results back
if tool_calls:
tool_results = []
for tool_call in tool_calls:
tool_name = tool_call.name
tool_input = tool_call.input
print(f"\n[Tool Call] {tool_name}({json.dumps(tool_input)})")
# Look up and run the corresponding Python function
if tool_name in TOOL_FUNCTIONS:
result = TOOL_FUNCTIONS[tool_name](**tool_input)
else:
result = f"Error: Tool '{tool_name}' not found."
print(f"[Tool Result] {result}")
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_call.id,
"content": result
})
# Add all tool results back into the message history as a user message
self.messages.append({
"role": "user",
"content": tool_results
})
# If stop reason is end_turn but there were tool calls, loop again
if stop_reason == "end_turn" and tool_calls:
continue
if not final_response:
final_response = "Agent reached max iterations without a final response."
return final_response
Now let's add the main block that ties everything together and makes the file runnable directly.
agent.py (continued)def main():
system_prompt = (
"You are a helpful business research assistant. "
"You have access to tools that let you do math, check weather, "
"and look up business information. "
"Always use the appropriate tool when it will help you give a more accurate answer. "
"Be concise and direct in your responses."
)
agent = Agent(system_prompt=system_prompt, tools=TOOLS)
print("=== Naples AI Agent Demo ===")
print("Type 'quit' to exit, 'reset' to start a new conversation.\n")
while True:
user_input = input("You: ").strip()
if not user_input:
continue
if user_input.lower() == "quit":
print("Goodbye!")
break
if user_input.lower() == "reset":
agent.reset()
print("[Conversation reset]\n")
continue
print("\nAgent: ", end="")
response = agent.run(user_input)
print(f"\n\nFinal: {response}\n")
if __name__ == "__main__":
main()
Step 5: Testing and Iterating Your Agent
Run the agent and try a few different inputs to see the loop in action. Start with something that requires a tool call, then try something that chains two tools together.
terminalpython agent.py
Here's a realistic sample of what a multi-turn interaction looks like when you run this agent. Notice how the loop tracks tool calls and results across iterations.
sample output=== Naples AI Agent Demo ===
Type 'quit' to exit, 'reset' to start a new conversation.
You: What's the weather in Naples and what is 15% of 82?
Agent:
[Agent Loop] Iteration 1
[Tool Call] get_weather({"city": "Naples, FL"})
[Tool Result] Weather in Naples: Sunny, 82°F, Humidity: 68%
[Tool Call] calculate({"expression": "0.15 * 82"})
[Tool Result] Result: 12.3
[Agent Loop] Iteration 2
The weather in Naples, FL is currently sunny and 82°F with 68% humidity.
15% of 82 is 12.3.
Final: The weather in Naples, FL is currently sunny and 82°F with 68% humidity.
15% of 82 is 12.3.
You: Tell me about Naples AI
Agent:
[Agent Loop] Iteration 1
[Tool Call] query_database({"business_name": "Naples AI"})
[Tool Result] {
"founded": "2024",
"location": "Naples, FL",
"services": "Custom AI development, process automation, AI chatbots",
"founder": "Chris Mejias"
}
[Agent Loop] Iteration 2
Naples AI is a custom AI solutions agency based in Naples, FL, founded in 2024
by Chris Mejias. They specialize in custom AI development, process automation,
and AI chatbots, serving businesses in Southwest Florida.
Final: Naples AI is a custom AI solutions agency based in Naples, FL, founded in 2024
by Chris Mejias. They specialize in custom AI development, process automation,
and AI chatbots, serving businesses in Southwest Florida.
You: reset
[Conversation reset]
You: quit
Goodbye!
Try breaking it intentionally too. Ask for a tool that doesn't exist, give it a bad math expression, or ask something that doesn't require any tool at all. Seeing how the agent handles each case tells you a lot about where you need to add better error handling.
How It Works
Here's what's actually happening under the hood when you send a message to the agent. Understanding this makes it much easier to debug and extend.
When you call agent.run("What's the weather in Naples?"), the agent appends your message to self.messages and sends the full conversation history to Claude along with your tool schemas. Claude reads the schemas, decides the get_weather tool is relevant, and instead of answering directly, it returns a response with stop_reason: "tool_use" and a tool call block containing the tool name and arguments it wants to use.
Your code intercepts that tool call, runs the actual get_weather Python function with those arguments, and appends the result back into the message history as a tool_result block. Then the loop runs again. This time, Claude has the weather data it needed, so it writes a final answer and returns with stop_reason: "end_turn". The loop exits and returns that text to you.
The streaming part means you see tokens appear in real time instead of waiting for the full response. For long agent runs, that's a much better user experience. The stream.get_final_message() call gives you the complete, structured response object after all the text has streamed through, which is where you extract tool call blocks.
Common Errors and Fixes
Error 1: anthropic.AuthenticationError
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error':
{'type': 'authentication_error', 'message': 'invalid x-api-key'}}
This means your API key isn't loading correctly. Double-check that your .env file is in the same directory where you're running the script, that the key name is exactly ANTHROPIC_API_KEY, and that you're calling load_dotenv() before you try to read it. Also verify there are no extra spaces or quotes around the key value in the .env file.
Error 2: Tool result added without a preceding tool use block
anthropic.BadRequestError: Error code: 400 - {'type': 'error', 'error':
{'type': 'invalid_request_error', 'message':
'tool_result block(s) provided when previous message does not contain tool_use block(s)'}}
This happens when the message history gets out of sync. Every tool_result message you add must directly follow an assistant message that contained a matching tool_use block with the same tool_use_id. The most common cause is calling agent.reset() mid-loop or manually editing self.messages in a way that breaks the sequence. Keep the history append logic inside the loop and don't modify it manually.
Error 3: Missing 'content' key when accessing streamed tool calls
AttributeError: 'TextDelta' object has no attribute 'type' # or KeyError: 'input' when accessing tool call blocks
This usually means you're trying to parse tool calls from the streaming events directly instead of from the final message object. During streaming, you only get delta events for text. Tool use blocks come
Naples AI