What You'll Build
If you've been searching for a how to build AI agents tutorial that actually runs without breaking, this is it. By the end of this guide, you'll have a working AI agent in Python that uses Claude's tool-use API to answer questions, call functions, and loop autonomously until a task is complete. The whole thing fits in under 50 lines of core logic — no frameworks, no magic.
The complete, working agent code is built step-by-step in the sections below. Each snippet connects to the next. By Step 5, you'll have the entire file ready to copy and run. No missing pieces.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (grab one at console.anthropic.com)
- Basic familiarity with Python classes and functions
anthropicPython SDK installed (pip install anthropic)- A terminal and a text editor — that's it
Step 1: Set Up Your Claude API Environment
First, install the Anthropic SDK if you haven't already. One command and you're done.
terminalpip install anthropic
Next, set your API key as an environment variable. Never hardcode it — that's how keys get leaked on GitHub.
terminal (Mac/Linux)export ANTHROPIC_API_KEY="sk-ant-your-key-here"
$env:ANTHROPIC_API_KEY="sk-ant-your-key-here"
Now let's verify the SDK is working before we build anything. Run this quick test to confirm your key is valid.
test_connection.pyimport anthropic
import os
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Say hello in one sentence."}]
)
print(message.content[0].text)
You should see something like: "Hello! It's great to connect with you today." If you get an authentication error, double-check that the environment variable is set in the same terminal session.
Step 2: Create Your First Agent Class
Here's where we wire up the agent's core structure. We're using a class to keep state — specifically the conversation history — across multiple turns. That's the key difference between a one-shot API call and an actual agent.
agent.pyimport anthropic
import os
import json
class ClaudeAgent:
def __init__(self, system_prompt: str, tools: list):
# Initialize the Anthropic client using the env variable
self.client = anthropic.Anthropic(
api_key=os.environ.get("ANTHROPIC_API_KEY")
)
self.model = "claude-sonnet-4-6"
self.system_prompt = system_prompt
self.tools = tools
self.messages = [] # Conversation history persists across turns
self.max_tokens = 4096
def add_user_message(self, content: str):
self.messages.append({"role": "user", "content": content})
def add_assistant_message(self, content):
self.messages.append({"role": "assistant", "content": content})
The messages list is what gives the agent memory. Every turn — user input, Claude's response, tool results — gets appended here so Claude always has context for the next step.
Claude's API is stateless — it doesn't remember past calls. By maintaining
self.messages in your class, you're replaying the full conversation on every API call. This is exactly how every production agent framework works under the hood.
Step 3: Define Tools for Your Agent
Tools are how Claude takes actions in the real world. You define them as JSON schemas, and Claude decides when and how to call them based on the task at hand. Think of them as the agent's hands.
For this tutorial, we'll give the agent three tools: a calculator, a weather lookup (simulated), and a text word counter. These cover the tool-use pattern cleanly without needing external API keys.
agent.py (continued — add below the class definition)# Tool schemas — Claude reads these to know what's available
TOOLS = [
{
"name": "calculate",
"description": "Perform basic arithmetic calculations. Use this for any math operations.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A math expression to evaluate, e.g. '15 * 24 + 7'"
}
},
"required": ["expression"]
}
},
{
"name": "get_weather",
"description": "Get the current weather for a given city.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'Naples, FL'"
}
},
"required": ["city"]
}
},
{
"name": "count_words",
"description": "Count the number of words in a given text string.",
"input_schema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to count words in"
}
},
"required": ["text"]
}
}
]
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Route tool calls to their actual implementations."""
if tool_name == "calculate":
try:
# Safely evaluate basic arithmetic only
allowed_chars = set("0123456789+-*/()., ")
expression = tool_input["expression"]
if all(c in allowed_chars for c in expression):
result = eval(expression)
return f"Result: {result}"
else:
return "Error: Invalid characters in expression."
except Exception as e:
return f"Calculation error: {str(e)}"
elif tool_name == "get_weather":
# Simulated weather data — swap this with a real API call in production
city = tool_input["city"]
weather_data = {
"Naples, FL": "Sunny, 84°F, humidity 72%",
"Miami, FL": "Partly cloudy, 88°F, humidity 80%",
"Tampa, FL": "Scattered thunderstorms, 79°F, humidity 85%",
}
return weather_data.get(city, f"Weather data not available for {city}.")
elif tool_name == "count_words":
text = tool_input["text"]
count = len(text.split())
return f"Word count: {count} words"
else:
return f"Unknown tool: {tool_name}"
The execute_tool function is your tool router. When Claude says "call the calculator with this expression," your code catches that, runs the actual Python logic, and feeds the result back to Claude. It's a simple dispatcher pattern that scales cleanly as you add more tools.
Step 4: Implement the Agentic Loop
This is the heart of every AI agent. The loop runs until Claude either finishes the task or hits your iteration limit. Each cycle: Claude responds, you check if it wants to call a tool, you run the tool, you feed the result back, repeat.
agent.py (continued — add as a method inside ClaudeAgent class) def run(self, user_input: str, max_iterations: int = 10) -> str:
"""
Main agentic loop. Keeps running until Claude stops requesting tool calls
or we hit the max iteration limit.
"""
self.add_user_message(user_input)
iteration = 0
while iteration < max_iterations:
iteration += 1
# Call the Claude API with current conversation history and tools
response = self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=self.system_prompt,
tools=self.tools,
messages=self.messages
)
# Store Claude's full response in message history
self.add_assistant_message(response.content)
# If Claude is done (no more tool calls), return the final text
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return "Task complete."
# If Claude wants to use tools, process each tool call
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
print(f" → Calling tool: {tool_name}({tool_input})")
# Execute the tool and capture its output
result = 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
})
# Feed all tool results back into the conversation
self.messages.append({
"role": "user",
"content": tool_results
})
else:
# Unexpected stop reason — break to avoid infinite loop
break
return "Max iterations reached. Task may be incomplete."
The stop_reason is what makes this loop smart. When it's "tool_use", Claude isn't done — it's asking for more information. When it's "end_turn", Claude has everything it needs and is giving you the final answer.
Without a cap, a buggy tool or an ambiguous task can cause the agent to loop forever — burning through API tokens and money. Ten iterations is a safe default for most tasks. Raise it only when you have a specific reason to.
Step 5: Test Your Agent with Real Examples
Now let's wire everything together and run some actual tasks. Here's the complete runnable script that pulls in everything we built above.
agent.py (complete file — full version)import anthropic
import os
import json
# ── Tool Schemas ──────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "calculate",
"description": "Perform basic arithmetic calculations. Use this for any math operations.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A math expression to evaluate, e.g. '15 * 24 + 7'"
}
},
"required": ["expression"]
}
},
{
"name": "get_weather",
"description": "Get the current weather for a given city.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'Naples, FL'"
}
},
"required": ["city"]
}
},
{
"name": "count_words",
"description": "Count the number of words in a given text string.",
"input_schema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to count words in"
}
},
"required": ["text"]
}
}
]
# ── Tool Executor ─────────────────────────────────────────────────────────────
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Route tool calls to their actual implementations."""
if tool_name == "calculate":
try:
allowed_chars = set("0123456789+-*/()., ")
expression = tool_input["expression"]
if all(c in allowed_chars for c in expression):
result = eval(expression)
return f"Result: {result}"
else:
return "Error: Invalid characters in expression."
except Exception as e:
return f"Calculation error: {str(e)}"
elif tool_name == "get_weather":
city = tool_input["city"]
weather_data = {
"Naples, FL": "Sunny, 84°F, humidity 72%",
"Miami, FL": "Partly cloudy, 88°F, humidity 80%",
"Tampa, FL": "Scattered thunderstorms, 79°F, humidity 85%",
}
return weather_data.get(city, f"Weather data not available for {city}.")
elif tool_name == "count_words":
text = tool_input["text"]
count = len(text.split())
return f"Word count: {count} words"
else:
return f"Unknown tool: {tool_name}"
# ── Agent Class ───────────────────────────────────────────────────────────────
class ClaudeAgent:
def __init__(self, system_prompt: str, tools: list):
self.client = anthropic.Anthropic(
api_key=os.environ.get("ANTHROPIC_API_KEY")
)
self.model = "claude-sonnet-4-6"
self.system_prompt = system_prompt
self.tools = tools
self.messages = []
self.max_tokens = 4096
def add_user_message(self, content: str):
self.messages.append({"role": "user", "content": content})
def add_assistant_message(self, content):
self.messages.append({"role": "assistant", "content": content})
def run(self, user_input: str, max_iterations: int = 10) -> str:
self.add_user_message(user_input)
iteration = 0
while iteration < max_iterations:
iteration += 1
response = self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=self.system_prompt,
tools=self.tools,
messages=self.messages
)
self.add_assistant_message(response.content)
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return "Task complete."
if response.stop_reason == "tool_use":
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
print(f" → Calling tool: {tool_name}({tool_input})")
result = 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.messages.append({
"role": "user",
"content": tool_results
})
else:
break
return "Max iterations reached. Task may be incomplete."
# ── Run Examples ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
SYSTEM_PROMPT = (
"You are a helpful assistant with access to tools. "
"Always use the appropriate tool when a task requires calculation, "
"weather data, or word counting. Be concise in your responses."
)
agent = ClaudeAgent(system_prompt=SYSTEM_PROMPT, tools=TOOLS)
# Example 1: Math task
print("\n── Example 1: Calculator ──")
result = agent.run("What is 347 multiplied by 82, then divided by 4?")
print(f"\nFinal answer: {result}")
# Reset conversation history for a fresh task
agent.messages = []
# Example 2: Weather lookup
print("\n── Example 2: Weather ──")
result = agent.run("What's the weather like in Naples, FL right now?")
print(f"\nFinal answer: {result}")
# Reset again
agent.messages = []
# Example 3: Multi-step task using two tools
print("\n── Example 3: Multi-tool task ──")
result = agent.run(
"Count the words in this sentence: 'The Naples AI agency builds custom solutions for local businesses.' "
"Then calculate how much 12 times that word count equals."
)
print(f"\nFinal answer: {result}")
Here's what the output looks like when you run this:
sample output── Example 1: Calculator ──
→ Calling tool: calculate({'expression': '347 * 82 / 4'})
← Tool result: Result: 7113.5
Final answer: 347 multiplied by 82 equals 28,454, and when divided by 4, the result is 7,113.5.
── Example 2: Weather ──
→ Calling tool: get_weather({'city': 'Naples, FL'})
← Tool result: Sunny, 84°F, humidity 72%
Final answer: The current weather in Naples, FL is sunny with a temperature of 84°F and humidity at 72%.
── Example 3: Multi-tool task ──
→ Calling tool: count_words({'text': 'The Naples AI agency builds custom solutions for local businesses.'})
← Tool result: Word count: 10 words
→ Calling tool: calculate({'expression': '12 * 10'})
← Tool result: Result: 120
Final answer: The sentence contains 10 words. When multiplied by 12, that gives you 120.
How It Works
Let me walk through what's actually happening under the hood — in plain English. When you call agent.run("some task"), the agent appends your message to its history and sends the whole conversation to Claude along with the tool schemas.
Claude reads the available tools and decides whether it needs to call one. If it does, the API returns a response with stop_reason: "tool_use" and a list of tool calls it wants to make. Your code catches those, runs the actual Python functions, and sends the results back as a new user message.
Claude then reads the tool results, reasons about them, and either calls another tool or writes the final answer. That cycle repeats until stop_reason is "end_turn", which is Claude's way of saying "I'm done, here's your answer." The whole loop is synchronous and deterministic — no surprises.
Common Errors and Fixes
Error 1: AuthenticationError — Invalid API key
anthropic.AuthenticationError: Error code: 401 - {'type': 'error', 'error': {'type': 'authentication_error', 'message': 'invalid x-api-key'}}
Fix: Your API key isn't being read correctly. Check that you ran export ANTHROPIC_API_KEY="sk-ant-..." in the same terminal window where you're running the script. Run echo $ANTHROPIC_API_KEY (Mac/Linux) or echo $env:ANTHROPIC_API_KEY (PowerShell) to confirm it's set. Don't wrap the key in extra quotes inside the string.
Error 2: BadRequestError — tool_result blocks in wrong position
anthropic.BadRequestError: Error code: 400 - {'type': 'error', 'error': {'type': 'invalid_request_error', 'message': 'tool_result blocks can only be in a user turn'}}
Fix: Tool results must be sent as a user role message — not as an assistant message. Check your agentic loop and confirm tool results are appended with "