AI Agents Lesson 3 of 6 ⏱️ 6 min read

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.

Updated Oct 30, 2026

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

Bash
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.

Python
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

Python
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

Python
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:

Plaintext
  → 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:

  1. Append the whole response.content, not just the text. It contains the tool_use blocks (and any thinking blocks) the next request must include.
  2. Return every tool result in one user message, each with the matching tool_use_id.
  3. Errors go back to the model with is_error: True so 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

  1. Add a search_wikipedia(topic) tool using the requests library.
  2. Log the number of steps and total response.usage.output_tokens per run.
  3. Before running any tool whose name starts with delete_, ask the user to type yes.

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_use block it refers to is missing.
  • Ignoring refusal and max_tokens. Always check stop_reason before assuming you have a final answer.

Key takeaways

  • An agent is ~60 lines: tools, a loop, and stop_reason handling.
  • 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.
Finished this lesson?
Tick it off to track your AI Agents progress.
Next lesson