Lesson 02 The Agent Loop from Scratch
Theme:
Lesson 02 · Slide 1

Lesson 02 — The Agent Loop from Scratch

Build a working agent in ~80 lines of Python. No frameworks. Just anthropic and a loop.

Agents Module 1 Python

Lesson 02 · Slide 2

Why build it from scratch

This lesson is the one time we build the whole thing by hand. Modules 2–5 hand it back to a framework.

Lesson 02 · Slide 3

What we're building

A single-file Python script — agent.py — that:

Lesson 02 · Slide 4

The Anthropic SDK in one slide

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from env

response = client.messages.create(
    model="claude-3-5-sonnet-latest",
    max_tokens=1024,
    system="You are a helpful assistant.",
    messages=[{"role": "user", "content": "Hello"}],
)
print(response.content[0].text)

Every agent we build in this course calls messages.create in a loop. That's it. Everything else is plumbing.

Lesson 02 · Slide 5

Defining tools for the LLM

TOOLS = [
    {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "input_schema": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
    # ... get_clothing_advice
]

The LLM sees these tool schemas on every call. Names, descriptions, and JSON Schemas are how it decides what to call and what arguments to pass.

Lesson 02 · Slide 6

stop_reason values

ValueMeaningLoop action
end_turnLLM produced a final answerexit loop
tool_useLLM wants to call a toolexecute, append result, continue
max_tokenshit max_tokens limitextend or fail
stop_sequencehit a stop sequenceusually exit

The whole agent loop is a switch statement on this one field.

Lesson 02 · Slide 7

The agent loop in pseudocode

messages = [{"role": "user", "content": user_q}]
for turn in range(MAX_TURNS):
    resp = client.messages.create(
        model=MODEL,
        system=SYSTEM,
        tools=TOOLS,
        messages=messages,
        max_tokens=1024,
    )
    if resp.stop_reason == "end_turn":
        return resp.content[-1].text
    if resp.stop_reason == "tool_use":
        # extract tool_use block, run tool, append result
        ...
raise RuntimeError("turn cap reached")

Eleven lines. Everything else in the file is plumbing around this shape.

Lesson 02 · Slide 8

Extracting the tool_use block

tool_uses = [b for b in resp.content if b.type == "tool_use"]
for tu in tool_uses:
    result = TOOL_FUNCS[tu.name](**tu.input)
    messages.append({"role": "assistant", "content": resp.content})
    messages.append({
        "role": "user",
        "content": [{
            "type": "tool_result",
            "tool_use_id": tu.id,
            "content": str(result),
        }],
    })

The assistant message must contain the full resp.content block list, then a user message carries the tool_result blocks back. The runtime is pretending to be the user when reporting tool output.

Lesson 02 · Slide 9

Mock tool implementations

def get_weather(city: str) -> str:
    # Deterministic fake — real version 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 else "")
    return "Light layers should be fine."

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

Deterministic mocks keep the lab cheap, fast, and reproducible. Swap in real APIs later — the loop doesn't change.

Lesson 02 · Slide 10

Cost tracking

total_input = total_output = 0
# inside loop, after each messages.create():
total_input += resp.usage.input_tokens
total_output += resp.usage.output_tokens
cost_usd = (total_input * 3 + total_output * 15) / 1_000_000  # Sonnet pricing
if cost_usd > MAX_COST_USD:
    raise RuntimeError("cost cap exceeded")

The cost cap is the second stop condition — it catches what MAX_TURNS misses: one expensive turn with a huge context window.

Lesson 02 · Slide 11

Retry on transient errors

from anthropic import APIStatusError
import time

for attempt in range(3):
    try:
        resp = client.messages.create(...)
        break
    except APIStatusError as e:
        if e.status_code >= 500 and attempt < 2:
            time.sleep(2 ** attempt)
            continue
        raise

Three attempts, exponential backoff, only on 5xx. 4xx errors are bugs in your request — retrying them just burns tokens.

Lesson 02 · Slide 12

Putting it all together

Notes: The full file is in the lab guide. Read the lab guide top to bottom — every line is annotated.
Lesson 02 · Slide 13

What's missing

Deliberately omitted from the from-scratch agent

  • Streaming responses
  • Parallel tool calls
  • Structured output / typed responses
  • Multi-agent orchestration
  • Observability & tracing
  • Persistent memory

All of these are what the frameworks in Modules 2–5 give you. Now you'll know what they're really doing under the hood — and the cost of writing them yourself.

Lesson 02 · Slide 14

Recap & what's next

Coming up: Module 2 — the Claude Agent SDK, which takes most of these 80 lines off your plate.
1 / ?