What You'll Build
If you've been searching for a real how to build AI agents tutorial — not a toy demo, but something you could actually ship — you're in the right place. 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 until it completes a task.
The agent runs in under 100 lines of clean Python and uses the official Anthropic SDK. You'll walk away understanding the agentic loop pattern well enough to extend it into your own projects.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python classes and functions
anthropicSDK installed:pip install anthropic- A terminal and a text editor or IDE
Step 1: Set Up Your Claude API Environment
First, install the Anthropic SDK and store your API key as an environment variable. Never hardcode keys in source files — especially if you're pushing to GitHub.
terminalpip install anthropic export ANTHROPIC_API_KEY="sk-ant-your-key-here"
Now create a new file called agent.py and confirm the SDK connects correctly with a quick smoke test.
import os
import anthropic
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# Quick connection test — remove this after confirming it works
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=64,
messages=[{"role": "user", "content": "Say hello in one word."}]
)
print(response.content[0].text)
Run it with python agent.py. If you see something like "Hello!" printed back, your environment is good to go.
set ANTHROPIC_API_KEY=sk-ant-your-key-here in Command Prompt, or use a .env file with the python-dotenv package.
Step 2: Define Your Agent's Tools and Schema
Tools are what turn a regular LLM call into an agent. You define each tool as a JSON schema — Claude reads the schema, decides when to use the tool, and returns structured arguments you can execute in Python.
For this tutorial, I'm building an agent that can check weather data and perform calculations. These are simple, but they demonstrate every concept you need for real-world tools like database lookups or API calls.
agent.pyimport os
import json
import anthropic
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
# Tool schemas tell Claude what each function does and what arguments it expects
TOOLS = [
{
"name": "get_weather",
"description": (
"Returns the current weather for a given city. "
"Use this when the user asks about weather conditions."
),
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'Naples, FL'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit to return"
}
},
"required": ["city"]
}
},
{
"name": "calculate",
"description": (
"Evaluates a basic math expression and returns the result. "
"Use this for arithmetic the user asks you to compute."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A math expression as a string, e.g. '42 * 7 + 15'"
}
},
"required": ["expression"]
}
}
]
The input_schema block follows JSON Schema conventions. The required array tells Claude which fields it must provide — it won't call your tool without them.
Step 3: Create the Main Agent Class
Now let's build the class that wraps everything. The ClaudeAgent class holds the conversation history, the tool definitions, and the logic to send messages to Claude.
import os
import json
import logging
import anthropic
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
TOOLS = [
{
"name": "get_weather",
"description": (
"Returns the current weather for a given city. "
"Use this when the user asks about weather conditions."
),
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'Naples, FL'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit to return"
}
},
"required": ["city"]
}
},
{
"name": "calculate",
"description": (
"Evaluates a basic math expression and returns the result. "
"Use this for arithmetic the user asks you to compute."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A math expression as a string, e.g. '42 * 7 + 15'"
}
},
"required": ["expression"]
}
}
]
class ClaudeAgent:
def __init__(self, system_prompt: str = "You are a helpful assistant."):
self.client = client
self.model = "claude-sonnet-4-6"
self.tools = TOOLS
self.system_prompt = system_prompt
# Conversation history persists across turns in a session
self.messages: list[dict] = []
self.max_tokens = 4096
def _call_claude(self) -> anthropic.types.Message:
"""Send the current message history to Claude and return the response."""
logger.info("Calling Claude with %d messages in history", len(self.messages))
return self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=self.system_prompt,
tools=self.tools,
messages=self.messages
)
def chat(self, user_input: str) -> str:
"""Add a user message and kick off the agentic loop."""
self.messages.append({"role": "user", "content": user_input})
return self._run_loop()
def _run_loop(self) -> str:
"""Agentic loop — keeps running until Claude returns a final text response."""
while True:
response = self._call_claude()
logger.info("Stop reason: %s", response.stop_reason)
# Claude is done — return the final text answer
if response.stop_reason == "end_turn":
final_text = next(
(block.text for block in response.content if hasattr(block, "text")),
""
)
# Add assistant response to history for multi-turn memory
self.messages.append({"role": "assistant", "content": response.content})
return final_text
# Claude wants to use a tool
if response.stop_reason == "tool_use":
self.messages.append({"role": "assistant", "content": response.content})
tool_results = self._execute_tools(response.content)
# Feed tool results back so Claude can continue reasoning
self.messages.append({"role": "user", "content": tool_results})
continue
# Unexpected stop reason — bail out gracefully
logger.warning("Unexpected stop reason: %s", response.stop_reason)
return "I encountered an unexpected state. Please try again."
The _run_loop method is the heart of this. It keeps calling Claude, executing tools when asked, and feeding results back until Claude signals it's done with end_turn.
Step 4: Implement the Tool Execution Loop
When Claude decides to use a tool, it returns a tool_use block with the tool name and the arguments it wants to pass. Your job is to route that to the right Python function and return the result.
def _execute_tools(self, content_blocks: list) -> list[dict]:
"""
Loop through content blocks, find tool_use blocks,
run each tool, and return a list of tool_result messages.
"""
tool_results = []
for block in content_blocks:
if block.type != "tool_use":
continue
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
logger.info("Executing tool: %s with input: %s", tool_name, tool_input)
try:
result = self._dispatch_tool(tool_name, tool_input)
except Exception as e:
# Return the error as a string so Claude can handle it gracefully
result = f"Error executing {tool_name}: {str(e)}"
logger.error("Tool execution failed: %s", e)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": str(result)
})
return tool_results
def _dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
"""Route tool calls to the correct Python function."""
if tool_name == "get_weather":
return self._tool_get_weather(
city=tool_input["city"],
unit=tool_input.get("unit", "fahrenheit")
)
elif tool_name == "calculate":
return self._tool_calculate(expression=tool_input["expression"])
else:
raise ValueError(f"Unknown tool: {tool_name}")
def _tool_get_weather(self, city: str, unit: str = "fahrenheit") -> str:
"""
Simulated weather tool. In production, replace this with a real
API call to OpenWeatherMap, WeatherAPI, or similar.
"""
mock_data = {
"naples": {"temp_f": 84, "temp_c": 29, "condition": "Sunny"},
"miami": {"temp_f": 87, "temp_c": 31, "condition": "Partly Cloudy"},
"new york": {"temp_f": 62, "temp_c": 17, "condition": "Overcast"},
}
city_key = city.lower().split(",")[0].strip()
weather = mock_data.get(city_key, {"temp_f": 72, "temp_c": 22, "condition": "Clear"})
temp = weather["temp_f"] if unit == "fahrenheit" else weather["temp_c"]
unit_symbol = "°F" if unit == "fahrenheit" else "°C"
return f"{city}: {weather['condition']}, {temp}{unit_symbol}"
def _tool_calculate(self, expression: str) -> str:
"""
Safe math evaluator using a restricted environment.
Never use bare eval() on untrusted input in production.
"""
allowed_names = {"__builtins__": {}}
# Only expose safe math operations
safe_math = {
"abs": abs, "round": round, "min": min, "max": max,
"sum": sum, "pow": pow
}
allowed_names.update(safe_math)
try:
result = eval(expression, allowed_names) # noqa: S307
return str(result)
except Exception as e:
return f"Math error: {str(e)}"
calculate tool uses a restricted eval() with no builtins and a limited safe-math namespace. For production, use a proper math parsing library like simpleeval instead.
Step 5: Add Error Handling and Logging
Now let's wire everything together with a main function that includes proper error handling. This is the part I see people skip — and then they can't debug when something goes wrong.
agent.py (complete final file)import os
import json
import logging
import anthropic
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))
TOOLS = [
{
"name": "get_weather",
"description": (
"Returns the current weather for a given city. "
"Use this when the user asks about weather conditions."
),
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. 'Naples, FL'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit to return"
}
},
"required": ["city"]
}
},
{
"name": "calculate",
"description": (
"Evaluates a basic math expression and returns the result. "
"Use this for arithmetic the user asks you to compute."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A math expression as a string, e.g. '42 * 7 + 15'"
}
},
"required": ["expression"]
}
}
]
class ClaudeAgent:
def __init__(self, system_prompt: str = "You are a helpful assistant."):
self.client = client
self.model = "claude-sonnet-4-6"
self.tools = TOOLS
self.system_prompt = system_prompt
self.messages: list[dict] = []
self.max_tokens = 4096
def _call_claude(self) -> anthropic.types.Message:
logger.info("Calling Claude with %d messages in history", len(self.messages))
return self.client.messages.create(
model=self.model,
max_tokens=self.max_tokens,
system=self.system_prompt,
tools=self.tools,
messages=self.messages
)
def chat(self, user_input: str) -> str:
self.messages.append({"role": "user", "content": user_input})
return self._run_loop()
def _run_loop(self) -> str:
while True:
response = self._call_claude()
logger.info("Stop reason: %s", response.stop_reason)
if response.stop_reason == "end_turn":
final_text = next(
(block.text for block in response.content if hasattr(block, "text")),
""
)
self.messages.append({"role": "assistant", "content": response.content})
return final_text
if response.stop_reason == "tool_use":
self.messages.append({"role": "assistant", "content": response.content})
tool_results = self._execute_tools(response.content)
self.messages.append({"role": "user", "content": tool_results})
continue
logger.warning("Unexpected stop reason: %s", response.stop_reason)
return "I encountered an unexpected state. Please try again."
def _execute_tools(self, content_blocks: list) -> list[dict]:
tool_results = []
for block in content_blocks:
if block.type != "tool_use":
continue
tool_name = block.name
tool_input = block.input
tool_use_id = block.id
logger.info("Executing tool: %s with input: %s", tool_name, tool_input)
try:
result = self._dispatch_tool(tool_name, tool_input)
except Exception as e:
result = f"Error executing {tool_name}: {str(e)}"
logger.error("Tool execution failed: %s", e)
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": str(result)
})
return tool_results
def _dispatch_tool(self, tool_name: str, tool_input: dict) -> str:
if tool_name == "get_weather":
return self._tool_get_weather(
city=tool_input["city"],
unit=tool_input.get("unit", "fahrenheit")
)
elif tool_name == "calculate":
return self._tool_calculate(expression=tool_input["expression"])
else:
raise ValueError(f"Unknown tool: {tool_name}")
def _tool_get_weather(self, city: str, unit: str = "fahrenheit") -> str:
mock_data = {
"naples": {"temp_f": 84, "temp_c": 29, "condition": "Sunny"},
"miami": {"temp_f": 87, "temp_c": 31, "condition": "Partly Cloudy"},
"new york": {"temp_f": 62, "temp_c": 17, "condition": "Overcast"},
}
city_key = city.lower().split(",")[0].strip()
weather = mock_data.get(city_key, {"temp_f": 72, "temp_c": 22, "condition": "Clear"})
temp = weather["temp_f"] if unit == "fahrenheit" else weather["temp_c"]
unit_symbol = "°F" if unit == "fahrenheit" else "°C"
return f"{city}: {weather['condition']}, {temp}{unit_symbol}"
def _tool_calculate(self, expression: str) -> str:
allowed_names = {"__builtins__": {}}
safe_math = {
"abs": abs, "round": round, "min": min, "max": max,
"sum": sum, "pow": pow
}
allowed_names.update(safe_math)
try:
result = eval(expression, allowed_names) # noqa: S307
return str(result)
except Exception as e:
return f"Math error: {str(e)}"
def main():
print("Claude AI Agent — type 'quit' to exit\n")
agent = ClaudeAgent(
system_prompt=(
"You are a helpful assistant with access to weather data and a calculator. "
"Use your tools whenever they would give a better answer than guessing."
)
)
while True:
try:
user_input = input("You: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nGoodbye!")
break
if not user_input:
continue
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
try:
response = agent.chat(user_input)
print(f"\nAgent: {response}\n")
except anthropic.APIConnectionError:
print("Connection error — check your internet and try again.")
except anthropic.AuthenticationError:
print("Invalid API key — check your ANTHROPIC_API_KEY environment variable.")
except anthropic.RateLimitError:
print("Rate limit hit — wait a moment and try again.")
except anthropic.APIStatusError as e:
print(f"API error {e.status_code}: {e.message}")
if __name__ == "__main__":
main()
That's the complete agent. Under 120 lines of actual logic, and it handles the full tool-use cycle.
Example Output and Debugging
Here's what a real session looks like when you run this. I'm including the log lines too so you can see exactly what's happening under the hood.
terminal outputClaude AI Agent — type 'quit' to exit
You: What's the weather like