Skip to content

All lessons

Area 03 // AI AgentsLesson 64 / 14025 min read

Capstone: build an agent without a framework

You build a complete agent in one file (three tools, the agent loop, stop conditions, error handling and a real Claude model, plus an offline scripted mode for tests), so you understand exactly what every agent framework does under the hood.

Think of it like this

Analogy

Learning to drive in a car with an automatic gearbox is easy, but a mechanic who has rebuilt a manual gearbox understands what every automatic is doing. This capstone is rebuilding the gearbox: once you've written an agent by hand, every framework becomes "oh, that's just the loop with extras".

The problem it solves

Agent frameworks hide the loop, which makes them quick to start and hard to debug: when an agent misbehaves, you need to know exactly what was sent to the model, what came back, how tool results were returned and why the loop stopped. Building one small agent by hand, with tests, gives you that understanding permanently.

How it works, step by step

The finished agent is examples/mini-agent/agent.py in the course repository (about 120 lines), with test_agent.py alongside. It answers travel-cost questions using three tools.

  1. Define the tools as plain Python functions: hotel_price(city, nights), calculator(expression) (arithmetic only, no code execution), and finish(answer).
  2. Describe them for the model: name, description and a strict JSON Schema each (tool calling).
  3. Write a system prompt: role, "use the tools, never guess numbers", "call finish when you know the answer".
  4. Hide the model behind one method, next_calls(messages) -> (tool_calls, tokens_used):
    • AnthropicModel calls client.messages.create(model=..., system=..., tools=..., messages=...), appends the assistant turn to the history, and returns its tool_use blocks.
    • ScriptedModel replays a fixed plan, for offline tests and CI.
  5. Write the loop (agent loop):
    • Ask the model for its next calls.
    • Run each call; on an exception, return {"error": ...} with is_error: True instead of crashing.
    • If finish succeeded, return its answer.
    • Append all tool results in one user message.
  6. Add stop conditions (stop conditions): a step limit, a token budget, and "the model answered without calling finish".
  7. Test the loop's behaviour, not the model's: reaching an answer, the step limit, the token budget, error recovery, and the calculator refusing code.
  8. Run it for real: AGENT_PROVIDER=anthropic python agent.py "…".
The whole agent on one page.

See it in code

Install
pip install anthropic pytest
Needs
Python 3.10+. Tests and the default scripted run need no key. The real run needs ANTHROPIC_API_KEY. Model ID as of November 2026.

The loop at the heart of agent.py (tools, schemas and the two model classes are in the file):

examples/mini-agent/agent.py (the loop)
def run(question: str, model, max_steps: int = 6, max_tokens: int = 30_000, log=print) -> str:
    messages = [{"role": "user", "content": question}]
    used = 0
    for step in range(1, max_steps + 1):                        # stop condition: step limit
        calls, tokens = model.next_calls(messages)
        used += tokens
        if not calls:
            return "Stopped: the model answered without calling finish."
        results = []
        for call in calls:                                       # run every call from this turn
            try:
                output = TOOLS[call.name](**call.input)
                ok = True
            except Exception as e:                               # errors go back to the model
                output, ok = {"error": str(e)}, False
            log(f"step {step}: {call.name}({json.dumps(call.input)}) -> {json.dumps(output)}")
            if call.name == "finish" and ok:                     # stop condition: task done
                return output["answer"]
            results.append({"type": "tool_result", "tool_use_id": call.id,
                            "content": json.dumps(output), "is_error": not ok})
        messages.append({"role": "user", "content": results})  # all results in ONE message
        if used > max_tokens:                                    # stop condition: budget
            return "Stopped: token budget used up."
    return "Stopped: step limit reached without an answer."
Terminal
cd examples/mini-agent
pytest -q
python agent.py
AGENT_PROVIDER=anthropic python agent.py "Cost of 2 nights in Porto plus a 150 GBP train?"
Example output (tests, then the offline scripted run)
.....                                                                    [100%]
5 passed in 0.01s
step 1: hotel_price({"city": "Lisbon", "nights": 3}) -> {"city": "Lisbon", "nights": 3, "total_gbp": 285}
step 2: calculator({"expression": "285 + 400"}) -> {"result": 685}
step 3: finish({"answer": "About 685 GBP: 285 for 3 hotel nights plus the 400 flight."}) -> {"answer": "About 685 GBP: 285 for 3 hotel nights plus the 400 flight."}
answer: About 685 GBP: 285 for 3 hotel nights plus the 400 flight.

With a real model, the steps are chosen by Claude instead of the script; the loop, tools and limits are identical.

A real example

Example

From capstone to a real internal agent

A team needed an agent that answers "how much will this customer's renewal cost?" using their pricing API, discount rules and a calculator. They started from exactly this file: swapped the hotel tool for get_plan_price and get_customer_discounts, kept calculator and finish, added tracing and a cost budget, and wrote twenty scripted-model tests for edge cases (unknown plans, expired discounts). Two weeks later it was in production behind a human approval step for quotes over £50,000.

The easy way

Start here

Copy examples/mini-agent, run the tests, then change one thing at a time: add a tool, change the question, set AGENT_PROVIDER=anthropic, lower max_steps and watch it stop. Print messages after each turn to see exactly what the model receives.

When you grow: the production setup

Turning this into a production agent means adding: tracing of every step, prompt caching for the system prompt and tools, retries with backoff and model fallbacks, idempotent action tools with permission checks, human approval for risky tools, context management for long tasks, durable state if it must pause, and evals of outcomes and trajectories, or moving to a framework once those needs pile up.

Trade-offs

What you gain

  • Complete understanding and control of every request and decision.
  • No framework dependency; easy to test with a scripted model.
  • Small enough to read in one sitting.

What it costs you

  • You build tracing, durability, multi-agent support and approvals yourself.
  • More code to maintain as requirements grow.
  • Easy to miss edge cases frameworks already handle.

When not to use it

  • You need durable checkpoints, complex graphs or many agents from day one: start from a framework, ideally after building this once.

Common mistakes

  • Forgetting to append the assistant turn, breaking tool_use/tool_result pairing.
  • Sending each tool result as a separate message.
  • No finish signal, so the loop's end is ambiguous.
  • Testing only with the real model (slow, costly, flaky) instead of a scripted one.
  • Letting a calculator tool run arbitrary code with eval on unvalidated input.

Interview questions

Try answering out loud first, then open the model answer.

Walk me through implementing an agent from scratch.

Model answer

Define tools as functions with clear descriptions and strict JSON Schemas. Keep a message list starting with the user's request. Loop with a step limit: call the model with system prompt, tools and messages; append its full turn; if it requested tools, execute each (in parallel where safe), catching errors into error results; append all results in one user message; stop on a finish tool or final answer, budget exhaustion or limits, returning partial progress when stopping early. Wrap the model in an interface so tests use a scripted model, and add tracing.

How do you test an agent?

Model answer

Unit-test tools directly. Test the loop with a scripted model covering success, tool errors, step and token limits, malformed calls and refusals, all offline. Then run evals with the real model on representative tasks, scoring final outcomes and trajectories (tool selection, arguments, efficiency), several runs per case to measure variance, tracked over time and in CI where cheap enough.

When would you move from a hand-written agent to a framework?

Model answer

When requirements outgrow the loop: durable state and resumption, human-in-the-loop pauses across processes, multi-agent coordination, complex branching, built-in tracing and evaluation integrations, or team-wide standardisation. Until then a small hand-written loop is clearer and easier to debug.

Quiz

Pick an answer to see whether it’s right and why.

  1. 01Why does the capstone agent have a finish tool?
  2. 02Why is the model behind a small interface (next_calls)?
  3. 03The hotel tool raises an error for an unknown city. What happens?
  4. 04A scripted model keeps calling the calculator forever. What stops it?
  5. 05Why must the model's own turn be appended before the tool results?

0 of 5 answered

Key terms

Agent
A system where a model decides, in a loop, which tools to call and what to do next until a task is done.
Agent loop
The cycle at the heart of every agent. The model picks an action, the action runs, the result is added to the context, repeat until a stop condition.
Tool calling (function calling)
A model replying with a request to run one of your functions, with arguments; your app runs it and sends the result back.
Stop condition
A rule that ends an agent loop, such as a final answer, a maximum number of steps, a token or time budget, or an error.
Tool result
The output of a tool your app ran, sent back to the model linked to the call's ID so it can continue.

Before and after

Progress is saved in this browser only.