The tutorial teaches you to build an AI agent with tool use from scratch in Python, using only the Anthropic SDK. No LangChain, no LangGraph, no abstractions. You write the agent loop, define the tools, and handle the conversation yourself.
Understanding what happens inside agent.run() is the difference between debugging an agent and guessing at one. When something breaks and it will, you need to know which layer the problem lives in: the tool definition, the tool execution, the message history, or the model's reasoning.
This tutorial builds a working agent step by step. By the end you will have a CLI tool that can search the web, do math, and remember what you told it three messages ago.
Prerequisites
- Python 3.10 or newer (check with
python3 --version) - An Anthropic API key (get one at console.anthropic.com)
- Basic familiarity with Python functions and dictionaries
What We Are Building
A command-line agent with three tools:
- Web search using the Tavily API (free tier: 1,000 searches/month)
- Calculator for math expressions
- File reader for looking up information from local files
The agent decides which tool to call based on your question, executes it, and uses the result to formulate an answer. If the first tool call does not give enough information, it can call another tool. This is the core loop behind every agent, from Claude Code to ChatGPT plugins.
Step 1: Install Dependencies
mkdir agent-from-scratch && cd agent-from-scratch
python3 -m venv .venv
source .venv/bin/activate
pip install anthropic tavily-python
Set your API keys:
export ANTHROPIC_API_KEY="sk-ant-..."
export TAVILY_API_KEY="tvly-..."
The Tavily key is free for the basic tier. Sign up at tavily.com if you do not have one yet.
Step 2: Define Your Tools
Anthropic's tool use API expects a JSON schema for each tool. The model reads these schemas to decide which tool fits the user's question.
# tools.py
"""Tool definitions for the agent."""
import json
import os
from tavily import TavilyClient
tavily = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
TOOLS = [
{
"name": "web_search",
"description": "Search the web for current information. Use this when you need to look up facts, find recent news, or research a topic.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}
},
{
"name": "calculate",
"description": "Evaluate a math expression. Use this for calculations.",
"input_schema": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A math expression to evaluate, e.g. '2 + 3 * 4'"
}
},
"required": ["expression"]
}
},
{
"name": "read_file",
"description": "Read the contents of a local text file. Use this to look up information the user has stored in a file.",
"input_schema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the file to read"
}
},
"required": ["path"]
}
}
]
def execute_tool(name: str, input_data: dict) -> str:
"""Run a tool by name and return the result as a string."""
try:
if name == "web_search":
result = tavily.search(query=input_data["query"], max_results=3)
snippets = []
for r in result.get("results", []):
snippets.append(f"{r['title']}
{r['url']}
{r['content']}")
return "
".join(snippets) if snippets else "No results found."
elif name == "calculate":
# Safe math eval with restricted builtins
allowed = {"__builtins__": {}}
result = eval(input_data["expression"], allowed)
return str(result)
elif name == "read_file":
path = input_data["path"]
if not os.path.exists(path):
return f"Error: file not found at {path}"
with open(path) as f:
content = f.read()
# Truncate very long files
if len(content) > 5000:
content = content[:5000] + "
... (truncated)"
return content
else:
return f"Unknown tool: {name}"
except Exception as e:
return f"Error executing {name}: {str(e)}"
A few design decisions here:
- The
TOOLSlist uses Anthropic's tool schema format directly. No wrapper, no framework translation layer. This is what the model sees. execute_toolis a plain function with a switch statement. When you debug a tool failure, you land exactly where the problem is.- The calculator uses
eval()with empty builtins. For anything beyond a tutorial, use a proper math parser likenumexprorasteval. This keeps the example short. - File reads are truncated at 5,000 characters. Sending a 100MB file to the model wastes tokens and hits context limits fast.
Step 3: Build the Agent Loop
This is the heart of it. The loop does three things: send messages to the model, check if the model wants to call a tool, execute it, and repeat until the model produces a text answer.
# agent.py
"""The agent loop. No frameworks, just API calls."""
import anthropic
from tools import TOOLS, execute_tool
client = anthropic.Anthropic()
SYSTEM_PROMPT = """You are a helpful assistant with access to tools.
Use tools when they help answer the user's question.
If a tool call fails, explain what went wrong and try a different approach.
Always cite your sources when using web search results."""
def run_agent(user_message: str, history: list[dict] | None = None) -> tuple[str, list[dict]]:
"""Run the agent loop and return (final_answer, updated_history)."""
if history is None:
history = []
# Add the user message
history.append({"role": "user", "content": user_message})
# Agent loop: keep going until the model gives a text response
while True:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=history
)
# Check if the model wants to call tools
if response.stop_reason == "tool_use":
# Collect all tool calls from this response
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result
})
# Add the assistant's response and tool results to history
history.append({"role": "assistant", "content": response.content})
history.append({"role": "user", "content": tool_results})
# Loop continues: model will see tool results and decide next step
elif response.stop_reason == "end_turn":
# Model produced a final text answer
text = ""
for block in response.content:
if block.type == "text":
text += block.text
history.append({"role": "assistant", "content": response.content})
return text, history
else:
# Unexpected stop reason
text = ""
for block in response.content:
if block.type == "text":
text += block.text
return text or f"Unexpected stop reason: {response.stop_reason}", history
The loop structure is the same pattern every agent framework uses under the hood:
- Send messages to the model
- If the model says
tool_use, execute the tools and add results back as a user message - If the model says
end_turn, extract the text and return it
The key detail is step 2: tool results go into the conversation as a user message with tool_result blocks. This is how Anthropic's API works. The model sees the tool output in its next turn and decides what to do with it.
Notice the while True loop. The model can call multiple tools in sequence before producing a final answer. For example, the user asks "What is the population of Tokyo and what is 15% of that?" The model calls web_search for the population, then calls calculate with the result, then produces a final answer. All in one agent loop.
Step 4: Add the CLI Interface
# main.py
"""CLI interface for the agent."""
from agent import run_agent
def main():
print("Agent ready. Type 'quit' to exit, 'clear' to reset history.
")
history = []
while True:
try:
user_input = input("You: ").strip()
except (EOFError, KeyboardInterrupt):
print("
Bye.")
break
if not user_input:
continue
if user_input.lower() == "quit":
break
if user_input.lower() == "clear":
history = []
print("History cleared.
")
continue
answer, history = run_agent(user_input, history)
print(f"
Agent: {answer}
")
if __name__ == "__main__":
main()
Step 5: Test It
python main.py
Try these conversations:
You: What is the current price of Bitcoin?
Agent: [searches the web, returns current price with sources]
You: What is 15% of that number?
Agent: [uses the previous context, calculates 15%, returns result]
You: Save that calculation in a file called notes.txt
Agent: [calls read_file to check if the file exists, then responds]
The second question works because the conversation history includes the Bitcoin price from the first answer. The model has enough context to calculate 15% without searching again.
Step 6: Handle Common Failure Modes
Tool call errors
If a tool fails, the model gets the error message and can try something different. The execute_tool function returns error strings instead of raising exceptions, so the model always gets a response.
Context window overflow
Long conversations hit the model's context limit. Here is a simple guard:
def trim_history(history: list[dict], max_messages: int = 40) -> list[dict]:
"""Keep the system prompt context by trimming old messages."""
# Always keep: first user message (contains initial context)
# Trim: middle messages when history gets long
if len(history) <= max_messages:
return history
return history[:2] + history[-(max_messages - 2):]
This preserves the first user message (which often contains important context) and the most recent messages. For production, use Anthropic's prompt caching to reduce costs on the parts of the conversation that do not change.
Hallucinated tool calls
Sometimes the model tries to use a tool that does not exist. The API handles this: if the model calls a tool not in your TOOLS list, it raises an error. Catch it and add a message telling the model which tools are available.
try:
response = client.messages.create(...)
except anthropic.BadRequestError as e:
if "tool" in str(e).lower():
# Model tried to use a tool that does not exist
history.append({"role": "assistant", "content": [
{"type": "text", "text": f"Tool call failed: {e}. Available tools: web_search, calculate, read_file"}
]})
continue
raise
When to Use a Framework Instead
This tutorial shows the fundamentals. Frameworks exist because the fundamentals get complicated at scale:
- LangGraph adds state machines, persistence, human-in-the-loop checkpoints, and parallel tool execution. Use it when your agent has branching logic or needs to survive crashes.
- Claude Agent SDK gives you pre-built patterns for tool use, multi-turn conversations, and MCP integration. Use it when you want to ship fast and the patterns fit your use case.
- OpenAI Agents SDK adds routing, guardrails, and handoffs between agents. Use it when you need multi-agent orchestration with safety checks.
The framework choice depends on the shape of your problem, not which one is "best." If your agent can be expressed in 100 lines of Python like this one, the framework is overhead. If you need persistence, retries, parallel execution, or human review, reach for a framework.
Where to Go Next
- Add prompt caching to reduce costs on repeated system prompts. Anthropic's cache hits cost 90% less than fresh prompts.
- Add streaming with
client.messages.stream()so users see tokens as they are generated instead of waiting for the full response. - Add MCP to give your agent access to databases, APIs, and other tools without hardcoding them.
- Read the full tool use documentation at docs.anthropic.com/en/docs/build-with-claude/tool-use.