Lesson 02 The Agent Loop from Scratch
Theme:

Lab 02 — Build an agent loop from scratch

Lesson 02 · Building Your Own Agents · hands-on, ~30 min

Goal: by the end of this lab you will have a single-file agent.py (~80 lines) that accepts a user question on the command line, calls Claude with two mock tools, executes those tools in-process, enforces all four stop conditions (natural exit, turn cap, cost cap, KeyboardInterrupt), and prints the final answer plus a cost summary. There is no notebook for this lab — the source of truth is this page. Copy the program below into your own agent.py and run it.

Prereqs

Step 1 — Create the file

Create agent.py anywhere on your machine — it does not have to live inside the course repo. This is your file, treat it as a scratchpad you can edit freely. A common choice is a sibling directory next to the course checkout, e.g. ~/code/agents-lab/agent.py.

Step 2 — Paste the full program

Below is the complete program. Copy the entire block into agent.py. It is roughly 80 lines of executable code (plus comments and blank lines) and uses nothing beyond the anthropic SDK and the Python standard library.

"""Hand-rolled agent loop — Lesson 02 of the Agents course.

Run:
    export ANTHROPIC_API_KEY=...
    python agent.py "What's the weather in Kyiv? Should I bring a jacket?"
"""
from __future__ import annotations

import os
import sys
import time

import anthropic

MODEL = "claude-3-5-sonnet-latest"
MAX_TURNS = 10
MAX_COST_USD = 0.10
# Sonnet pricing (2026 Q2): $3 / 1M input, $15 / 1M output.
INPUT_USD_PER_M = 3.0
OUTPUT_USD_PER_M = 15.0

SYSTEM = (
    "You are a helpful assistant with access to two tools: get_weather "
    "and get_clothing_advice. Use them to answer the user's question, "
    "then give a one-sentence final answer."
)

# --- Tool implementations ------------------------------------------------

def get_weather(city: str) -> str:
    # Mock: a real implementation would call a weather API.
    return f"{city}: 4°C, light rain"


def get_clothing_advice(temp_c: int, condition: str) -> str:
    if temp_c < 10:
        return "Wear a warm jacket." + (" Bring an umbrella." if "rain" in condition.lower() else "")
    return "Light layers should be fine."


TOOL_FUNCS = {
    "get_weather": get_weather,
    "get_clothing_advice": get_clothing_advice,
}

TOOLS = [
    {
        "name": "get_weather",
        "description": "Get current weather for a city as 'temp°C, conditions'.",
        "input_schema": {
            "type": "object",
            "properties": {"city": {"type": "string", "description": "City name."}},
            "required": ["city"],
        },
    },
    {
        "name": "get_clothing_advice",
        "description": "Suggest clothing given temperature and conditions.",
        "input_schema": {
            "type": "object",
            "properties": {
                "temp_c": {"type": "integer"},
                "condition": {"type": "string"},
            },
            "required": ["temp_c", "condition"],
        },
    },
]

# --- The loop ------------------------------------------------------------

def run_agent(user_question: str) -> tuple[str, float]:
    client = anthropic.Anthropic()
    messages: list[dict] = [{"role": "user", "content": user_question}]
    total_in = total_out = 0

    for turn in range(MAX_TURNS):
        resp = client.messages.create(
            model=MODEL,
            max_tokens=1024,
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )
        total_in += resp.usage.input_tokens
        total_out += resp.usage.output_tokens
        cost = (total_in * INPUT_USD_PER_M + total_out * OUTPUT_USD_PER_M) / 1_000_000
        if cost > MAX_COST_USD:
            raise RuntimeError(f"cost cap exceeded after turn {turn}: ${cost:.4f}")

        if resp.stop_reason == "end_turn":
            text = "".join(b.text for b in resp.content if b.type == "text")
            return text.strip(), cost

        if resp.stop_reason != "tool_use":
            raise RuntimeError(f"unexpected stop_reason: {resp.stop_reason}")

        # Append the assistant turn verbatim, then run each tool and append its result.
        messages.append({"role": "assistant", "content": resp.content})
        tool_results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            func = TOOL_FUNCS.get(block.name)
            if func is None:
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": f"unknown tool: {block.name}",
                    "is_error": True,
                })
                continue
            try:
                out = func(**block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": str(out),
                })
            except Exception as e:
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": f"tool error: {e}",
                    "is_error": True,
                })
        messages.append({"role": "user", "content": tool_results})

    raise RuntimeError(f"turn cap ({MAX_TURNS}) reached without end_turn")


# --- CLI -----------------------------------------------------------------

if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("usage: python agent.py 'your question here'")
    question = sys.argv[1]
    try:
        answer, cost_usd = run_agent(question)
    except KeyboardInterrupt:
        sys.exit("\ninterrupted by user")
    print(answer)
    print(f"\n[cost: ${cost_usd:.4f}]")

That is the whole program — about 80 lines of executable code plus comments. Inline highlight.js renders it with syntax colors on this page so you can read it before you paste, but the file on disk is plain Python.

Step 3 — Run it

python agent.py "What's the weather in Kyiv? Should I bring a jacket?"

Expected: the agent runs 2 turns — one to call the tools, one to summarise. Output ends with something like:

It's 4°C with light rain in Kyiv — wear a warm jacket and bring an umbrella.

[cost: $0.0042]

Exact wording and exact cost vary turn-to-turn; what matters is that the loop terminates with stop_reason == "end_turn" and prints a sensible one-sentence answer followed by a cost line.

Step 4 — Walk through each section of the file

A guided tour of the program, matching the slides:

Step 5 — Trigger the cost cap deliberately

Edit the constant near the top of the file:

MAX_COST_USD = 0.0001  # one-hundredth of a cent

Re-run the same command. Expected output:

RuntimeError: cost cap exceeded after turn 0: $0.001...

Even the very first response burns more than $0.0001 of tokens, so the cap fires on turn 0. This is the cost stop condition in action. Restore MAX_COST_USD = 0.10 before moving on.

Step 6 — Trigger the tool-error path

Make get_weather blow up unconditionally:

def get_weather(city: str) -> str:
    raise RuntimeError("API down")

Re-run. The loop will not crash — instead the LLM will receive a tool_result block with is_error: true on the next turn. From there it will either (a) try a different approach (maybe call get_clothing_advice with a guessed temperature) or (b) give a final answer admitting it couldn't get the weather. Either outcome is correct: it shows the model recovering from a tool failure on its own, exactly the behaviour the is_error flag is designed to enable. Restore the working implementation before moving on.

Step 7 — Trigger the turn cap

Now make get_weather never succeed and never error — it just keeps asking for more information:

def get_weather(city: str) -> str:
    return "need more details — call me again with the country code"

Re-run. The LLM will keep calling get_weather trying to satisfy the apparent contract, and the loop will tick the turn counter up each iteration. After 10 turns:

RuntimeError: turn cap (10) reached without end_turn

This is the turn stop condition. It exists precisely for cases where a misbehaving tool (or a confused model) would otherwise loop forever. Restore the original get_weather.

What you should leave with

Troubleshooting

Common failure modes