Build an AI Agent from Scratch in Python
Write a working AI agent in about 60 lines of Python — tools, the agent loop, stop reasons, errors and a step limit.
What we're building
In this lesson you'll write a complete, working AI agent in Python — no framework, about 60 lines. It has two tools:
calculate— evaluates arithmetic exactly (LLMs are bad at precise maths).get_current_time— returns the current date and time (the model can't know it).
Ask it "How many days until 1 January 2027, and what is that times 24?" and it will call both tools, in the right order, before answering.
Building one by hand first is the best way to understand what agent frameworks like LangChain and LangGraph do for you.
Setup
pip install anthropic
export ANTHROPIC_API_KEY="your-key-here" # Windows PowerShell: $env:ANTHROPIC_API_KEY="your-key-here"
Step 1 — define the tools
Each tool has a Python function and a definition the model reads.
import ast
import operator
from datetime import datetime
# Safe arithmetic: only numbers and + - * / ** ( ) are allowed, never eval().
_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg,
}
def _eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.operand))
raise ValueError("Only numbers and + - * / ** ( ) are allowed.")
def calculate(expression: str) -> str:
return str(_eval(ast.parse(expression, mode="eval").body))
def get_current_time() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
TOOLS = [
{
"name": "calculate",
"description": "Evaluate an arithmetic expression exactly. Use this for any calculation instead of doing maths in your head.",
"input_schema": {
"type": "object",
"properties": {"expression": {"type": "string", "description": "e.g. '(365 - 12) * 24'"}},
"required": ["expression"],
},
},
{
"name": "get_current_time",
"description": "Get today's date and the current local time. Use this whenever the answer depends on the current date.",
"input_schema": {"type": "object", "properties": {}},
},
]
HANDLERS = {"calculate": calculate, "get_current_time": get_current_time}
Notice calculate never calls eval(). A model can be tricked into sending malicious input, so tool code must be safe on its own.
Step 2 — write the agent loop
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
MODEL = "claude-opus-5-5"
MAX_STEPS = 10 # hard limit so the agent can never loop forever
def run_agent(goal: str) -> str:
messages = [{"role": "user", "content": goal}]
for step in range(MAX_STEPS):
response = client.messages.create(
model=MODEL,
max_tokens=16000,
system="You are a precise assistant. Use the tools for dates and arithmetic.",
tools=TOOLS,
messages=messages,
)
# Keep the full assistant turn (text, thinking and tool_use blocks) in history.
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
return "".join(b.text for b in response.content if b.type == "text")
if response.stop_reason == "pause_turn":
continue # the model paused a long turn; send it back to continue
if response.stop_reason in ("max_tokens", "refusal"):
return f"Stopped early: {response.stop_reason}"
# stop_reason == "tool_use": run every requested tool, return ALL results in one message
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f" → {block.name}({block.input})")
try:
output = HANDLERS[block.name](**block.input)
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
except Exception as exc:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": f"Error: {exc}", "is_error": True})
messages.append({"role": "user", "content": results})
return "Stopped: reached the step limit."
Step 3 — run it
if __name__ == "__main__":
answer = run_agent("How many days until 1 January 2027, and what is that number times 24?")
print("\n" + answer)
Typical output:
→ get_current_time({})
→ calculate({'expression': '(365 - 302) + 1'})
→ calculate({'expression': '64 * 24'})
There are 64 days until 1 January 2027 — that's 1,536 hours.
The exact numbers depend on today's date — which is precisely why the agent needed a tool.
How the loop works
stop_reason |
Meaning | What the loop does |
|---|---|---|
tool_use |
Model wants tools | Run them, append results, loop again |
end_turn |
Model is finished | Return the text answer |
pause_turn |
A long turn was paused | Send the history back to continue |
max_tokens |
Ran out of output budget | Stop (or retry with a larger limit) |
refusal |
Model declined for safety reasons | Stop and report it |
Three details matter in production:
- Append the whole
response.content, not just the text. It contains thetool_useblocks (and any thinking blocks) the next request must include. - Return every tool result in one user message, each with the matching
tool_use_id. - Errors go back to the model with
is_error: Trueso it can recover — e.g. rewrite an invalid expression.
Don't want to write the loop?
The official SDKs include a tool runner helper that drives this loop for you from decorated Python functions, and frameworks like LangGraph add state, persistence and human approval steps. Writing the loop once by hand means you'll understand exactly what those tools automate.
Exercises
- Add a
search_wikipedia(topic)tool using therequestslibrary. - Log the number of steps and total
response.usage.output_tokensper run. - Before running any tool whose name starts with
delete_, ask the user to typeyes.
Common mistakes
- No
MAX_STEPS. An agent that misunderstands a tool error can call it forever. - Using
eval()for a calculator tool. That turns a maths tool into remote code execution. - Appending only text to history. The next request fails because the
tool_useblock it refers to is missing. - Ignoring
refusalandmax_tokens. Always checkstop_reasonbefore assuming you have a final answer.
Key takeaways
- An agent is ~60 lines: tools, a loop, and
stop_reasonhandling. - Your tools must be safe on their own — never trust model input.
- Keep full turns in history, return all results together, and cap the number of steps.