If you've been searching for a real, working build AI agents tutorial using Claude API, you're in the right place. Most guides stop at "here's how to send a message" — this one goes all the way to a fully functional autonomous agent that can reason, call tools, and loop until a task is done.
By the end of this tutorial, you'll have built something you can actually run, extend, and drop into a real project. Let's get into it.
What You'll Build
You're going to build a Python-based autonomous AI agent powered by Claude's API that can use tools to answer multi-step questions. Specifically, the agent will be able to search for information, perform calculations, and check the current date — all without you telling it what to do next.
The agent runs an agentic loop: it reasons about what tools it needs, calls them, processes the results, and keeps going until it has a final answer. This is the same core architecture used in production AI systems, just stripped down to the essentials so you can understand every piece.
Prerequisites
- Python 3.9 or higher installed
- An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python functions and dictionaries
anthropicPython SDK installed (pip install anthropic)- Optional but helpful: some experience with REST APIs
The complete, working code for this tutorial is built up step by step in the sections below. By the time you reach Step 4, you'll have every piece you need. You can copy each snippet in order or jump to the full working example at the end of Step 4.
Step 1: Set Up Your Claude API Environment
First, install the Anthropic SDK if you haven't already. Open your terminal and run:
terminalpip install anthropic
Next, set your API key as an environment variable. Never hardcode credentials in your source files.
terminalexport ANTHROPIC_API_KEY="sk-ant-your-key-here"
Now let's write the agent initialization. This is the foundation everything else builds on.
agent_init.pyimport anthropic
import os
# Initialize the Anthropic client using the ANTHROPIC_API_KEY env variable
client = anthropic.Anthropic()
# The model we'll use throughout this tutorial
MODEL = "claude-sonnet-4-6"
def test_connection():
"""Quick sanity check to confirm the API key and model are working."""
response = client.messages.create(
model=MODEL,
max_tokens=64,
messages=[
{"role": "user", "content": "Say 'Agent online.' and nothing else."}
]
)
print(response.content[0].text)
if __name__ == "__main__":
test_connection()
Run it. If you see Agent online. printed in your terminal, you're connected and ready to build. If you get an authentication error, double-check your environment variable is exported in the same shell session.
Agent online.
Step 2: Define Agent Tools and Capabilities
Tools are what separate a basic chatbot from an actual agent. A tool is just a function your agent can decide to call when it needs specific information or needs to perform an action it can't do on its own.
Claude uses a structured JSON schema to understand what tools are available and how to call them. You define the schema, Claude decides when to use each tool, and your Python code executes it.
Here are three tools we'll give our agent: a web search simulator, a calculator, and a date checker. In a real system, your search tool would hit an actual API like Brave Search or SerpAPI. For this tutorial, I'm keeping the implementations simple so you can see the structure clearly.
tools.pyimport anthropic
import math
from datetime import datetime
# ─── Tool Schema Definitions ─────────────────────────────────────────────────
# These are what Claude sees — it uses these schemas to know what tools exist
# and what arguments to pass when it wants to call one.
TOOLS = [
{
"name": "web_search",
"description": (
"Search the web for current information about 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 to look up."
}
},
"required": ["query"]
}
},
{
"name": "calculator",
"description": (
"Evaluate a mathematical expression and return the numeric result. "
"Supports standard arithmetic, exponents, and basic math 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": "get_current_date",
"description": "Returns today's date and current time in UTC. No input required.",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
}
]
# ─── Tool Implementations ─────────────────────────────────────────────────────
def web_search(query: str) -> str:
"""
Simulated web search. In production, replace this with a real search API
like Brave Search, SerpAPI, or Tavily.
"""
mock_results = {
"naples florida population": (
"Naples, Florida has a population of approximately 22,000 in the city proper, "
"with the greater Naples metro area (Collier County) exceeding 380,000 residents as of 2025."
),
"anthropic claude": (
"Anthropic is an AI safety company that develops the Claude family of large language models. "
"Claude models are known for strong reasoning, long context windows, and tool use capabilities."
),
"python latest version": (
"Python 3.13 is the latest stable release as of early 2026, featuring improved error messages "
"and a new experimental free-threaded mode."
),
}
# Normalize the query for matching
normalized = query.lower().strip()
for key, value in mock_results.items():
if key in normalized or normalized in key:
return value
return f"Search results for '{query}': No cached results found. In production, this would return live web data."
def calculator(expression: str) -> str:
"""Safely evaluate a math expression using Python's math module."""
try:
# Allow access to the math module inside expressions
allowed_names = {"math": math, "__builtins__": {}}
result = eval(expression, allowed_names) # noqa: S307
return f"Result: {result}"
except Exception as e:
return f"Error evaluating expression '{expression}': {e}"
def get_current_date() -> str:
"""Return the current UTC date and time."""
now = datetime.utcnow()
return f"Current UTC date and time: {now.strftime('%A, %B %d, %Y at %H:%M:%S UTC')}"
# ─── Tool Dispatcher ──────────────────────────────────────────────────────────
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""
Route a tool call from Claude to the correct Python function.
Returns a string result that gets fed back into the conversation.
"""
if tool_name == "web_search":
return web_search(tool_input["query"])
elif tool_name == "calculator":
return calculator(tool_input["expression"])
elif tool_name == "get_current_date":
return get_current_date()
else:
return f"Unknown tool: {tool_name}"
Claude doesn't execute your tools — it just decides when to use them and what arguments to pass. The schema is the contract between Claude's reasoning and your code. If your description is vague, Claude will misuse the tool or skip it entirely. Write descriptions like you're explaining the function to a smart coworker who has never seen your codebase.
Step 3: Create the Agent Decision Loop
This is the core of the whole system. The agentic loop is what makes an agent different from a single API call. Claude looks at the conversation, decides if it needs a tool, calls it, gets the result, then either calls another tool or produces a final answer.
The loop keeps running until Claude stops requesting tools and returns a plain text response. That's your signal that the agent has finished reasoning and has an answer for you.
agent_loop.pyimport anthropic
from tools import TOOLS, execute_tool
client = anthropic.Anthropic()
MODEL = "claude-sonnet-4-6"
SYSTEM_PROMPT = """You are a helpful research assistant with access to tools.
When answering questions:
1. Use tools to gather accurate information — don't guess.
2. Chain multiple tool calls if needed to fully answer the question.
3. Once you have enough information, give a clear, concise final answer.
4. Always show your reasoning before giving the final answer."""
def run_agent(user_message: str) -> str:
"""
Run the agentic decision loop for a single user query.
Returns the agent's final text response.
"""
print(f"\n{'='*60}")
print(f"USER: {user_message}")
print(f"{'='*60}")
# Start the conversation with the user's message
messages = [
{"role": "user", "content": user_message}
]
# The loop continues until Claude returns a stop_reason of "end_turn"
# (meaning it no longer needs to call any tools)
loop_count = 0
max_loops = 10 # Safety limit to prevent infinite loops
while loop_count < max_loops:
loop_count += 1
print(f"\n[Loop {loop_count}] Sending request to Claude...")
response = client.messages.create(
model=MODEL,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages
)
print(f"[Loop {loop_count}] Stop reason: {response.stop_reason}")
# If Claude is done reasoning and has a final answer, return it
if response.stop_reason == "end_turn":
final_text = ""
for block in response.content:
if hasattr(block, "text"):
final_text += block.text
print(f"\nFINAL ANSWER:\n{final_text}")
return final_text
# If Claude wants to use tools, process each tool call
if response.stop_reason == "tool_use":
# Add Claude's response (including tool use blocks) to message history
messages.append({
"role": "assistant",
"content": response.content
})
# Build the tool results to send back
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"[Tool Call] {tool_name}({tool_input})")
# Execute the tool and capture the result
result = execute_tool(tool_name, tool_input)
print(f"[Tool Result] {result[:120]}...") # Preview first 120 chars
tool_results.append({
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": result
})
# Feed all tool results back into the conversation as a user message
messages.append({
"role": "user",
"content": tool_results
})
else:
# Unexpected stop reason — break to avoid an infinite loop
print(f"[Warning] Unexpected stop_reason: {response.stop_reason}. Exiting loop.")
break
return "Agent reached maximum loop iterations without a final answer."
if __name__ == "__main__":
answer = run_agent(
"What is the population of Naples, Florida? "
"Also, what is the square root of that city's approximate metro population (380000)? "
"And what day is it today?"
)
Notice that every time Claude calls a tool, you add both the assistant's tool-use response AND the tool result back into the messages list. This is critical — Claude needs the full conversation history to reason correctly about what it's already done.
Step 4: Implement the Tool Execution Framework (Full Working Example)
Now let's put it all together into one clean, runnable file. This is the complete agent — everything from initialization to final output in a single script you can run right now.
full_agent.py"""
Full working AI agent built with Claude API (claude-sonnet-4-6)
Demonstrates: tool schemas, agentic loop, multi-tool chaining
"""
import anthropic
import math
import os
from datetime import datetime
# ─── Client Setup ─────────────────────────────────────────────────────────────
client = anthropic.Anthropic() # Reads ANTHROPIC_API_KEY from environment
MODEL = "claude-sonnet-4-6"
# ─── Tool Schemas ─────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "web_search",
"description": (
"Search the web for current information about 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 to look up."
}
},
"required": ["query"]
}
},
{
"name": "calculator",
"description": (
"Evaluate a mathematical expression and return the numeric result. "
"Supports standard arithmetic, exponents, and Python math functions."
),
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression, e.g. 'math.sqrt(144)' or '2 ** 10'."
}
},
"required": ["expression"]
}
},
{
"name": "get_current_date",
"description": "Returns today's date and current time in UTC. No input required.",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
}
]
# ─── Tool Implementations ─────────────────────────────────────────────────────
def web_search(query: str) -> str:
"""Simulated search — replace with Brave/SerpAPI/Tavily in production."""
mock_db = {
"naples florida population": (
"Naples, Florida has a city population of approximately 22,000. "
"The greater Naples metro area (Collier County) has over 380,000 residents as of 2025."
),
"anthropic claude": (
"Anthropic is an AI safety company. Claude is their flagship LLM with strong tool use, "
"reasoning, and long-context capabilities."
),
"python latest version": (
"Python 3.13 is the latest stable release as of early 2026."
),
}
normalized = query.lower().strip()
for key, value in mock_db.items():
if key in normalized or normalized in key:
return value
return f"No cached result for '{query}'. In production, live web results would appear here."
def calculator(expression: str) -> str:
"""Safely evaluate a mathematical expression."""
try:
allowed = {"math": math, "__builtins__": {}}
result = eval(expression, allowed) # noqa: S307
return f"Result: {result}"
except Exception as e:
return f"Calculation error for '{expression}': {e}"
def get_current_date() -> str:
"""Return the current UTC date and time."""
now = datetime.utcnow()
return f"Current UTC date and time: {now.strftime('%A, %B %d, %Y at %H:%M:%S UTC')}"
def execute_tool(tool_name: str, tool_input: dict) -> str:
"""Dispatch a tool call to the appropriate function."""
dispatch = {
"web_search": lambda: web_search(tool_input["query"]),
"calculator": lambda: calculator(tool_input["expression"]),
"get_current_date": lambda: get_current_date(),
}
handler = dispatch.get(tool_name)
if handler:
return handler()
return f"Unknown tool requested: {tool_name}"
# ─── Agent Loop ───────────────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are a helpful research assistant with access to tools.
When answering questions:
1. Use tools to gather accurate information — don't guess.
2. Chain multiple tool calls if the question requires it.
3. Once you have all the information you need, provide a clear and concise final answer.
4. Show your reasoning before giving the final answer."""
def run_agent(user_message: str) -> str:
"""Run the agentic loop and return the agent's final response."""
print(f"\n{'='*60}")
print(f"USER QUERY: {user_message}")
print(f"{'='*60}\n")
messages = [{"role": "user", "content": user_message}]
max_loops = 10
for loop_num in range(1, max_loops + 1):
print(f"[Iteration {loop_num}] Querying Claude...")
response = client.messages.create(
model=MODEL,
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages
)
stop_reason = response.stop_reason
print(f"[Iteration {loop_num}] Stop reason: {stop_reason}")
# Claude finished — extract and return the final text answer
if stop_reason == "end_turn":
return "".join(
block.text for block in response.content if hasattr(block, "text")
)
# Claude wants to call one or more tools
if stop_reason == "tool_use":
# Append Claude's full response to conversation history
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f" → Tool: {block.name} | Input: {block.input}")
result = execute_tool(block.name, block.input)
print(f" ← Result: {result[:100]}{'...' if len(result) > 100 else ''}")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Return all tool results to Claude as a single user turn
messages.append({"role": "user", "content": tool_results})
else:
print(f"[Warning] Unexpected stop_reason '{stop_reason}'. Stopping.")
break
return "Agent did not reach a final answer within the iteration limit."
# ─── Main ─────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
query = (
"What is the population of the Naples, Florida metro area? "
"Calculate the square root of that population number. "
"Also tell me today's date."
)
final_answer = run_agent(query)
print(f"\n{'='*60}")
print("AGENT FINAL ANSWER:")
print(f"{'='*60}")
print(final_answer)
Here's what the output looks like when you run this script:
expected output============================================================
USER QUERY: What is the population of the Naples, Florida metro area?
Calculate the square root of that population number.
Also tell me today's date.
============================================================
[Iteration 1] Querying Claude...
[Iteration 1] Stop reason: tool_use
→ Tool: web_search | Input: {'query': 'naples florida population'}
← Result: Naples, Florida has a city population of approximately 22,000. The greater Naples metro ...
→ Tool: get_current_date | Input: {}
← Result: Current UTC date and time: Sunday, April 12, 2026 at 14:23:07 UTC
[Iteration 2] Querying Claude...
[Iteration 2] Stop reason: tool_use
→ Tool: calculator | Input: {'expression': 'math.sqrt(380000)'}
← Result: Result: