Module 2: The Agent Loop · Lesson 1 of 5 · 35 min

The Loop That Makes an Agent

A chatbot maps one input to one output. An agent runs a loop where the model itself decides which tools to call, in what order, until the task is done. The loop is ~20 lines; everything else in this module is guardrails around it.

In Module 1 you built one tool-use round trip. The jump to an agent is smaller than the hype suggests: you put that round trip inside a while loop and let the model keep going. The defining property is who chooses the control flow. In a chatbot (or a workflow), your code decides what happens next. In an agent, the model decides — which tool, which arguments, whether to keep digging or stop. Same API, radically different system behavior.

DimensionChatbotAgent
Control flowOne request → one response; your code owns every stepModel picks the next action each iteration; path emerges at runtime
Tool callsZero or one, hardcoded by youZero to many, sequenced by the model
Cost & latencyPredictable: one callVariable: N calls, unknown N until it runs
Failure surfaceBad answerBad answer, infinite loops, runaway cost, wrong tool spirals
When it shinesThe path is known in advanceThe path can't be predetermined (research, debugging, open-ended tasks)
LLMreason + decideToolsyour code runsstartmessages[]USER“summarize my notes on RAG”ASSISTANTtool_call search_notes(“RAG”)TOOL→ 5 snippets [n12, n41 …]ASSISTANTtool_call read_note(n41)TOOL→ note body (820 tokens)ASSISTANT“Your RAG notes cover 3…” · no toolmessages = [ user task ]
The loop: LLM → tool call → your code executes → result back into messages → LLM again, until the model stops asking for tools.
1/6

The canonical loop

a complete, runnable agent — paste straight into Colab
# Colab cell 1 — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic

import os
try:
    from google.colab import userdata
    os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
    from getpass import getpass
    os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))

import anthropic

client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"

# A tiny in-memory "notes database" so the tools do real work — no setup,
# no files. Swap this for a real store later; the loop below never changes.
NOTES = {
    "n042": {"title": "Exponential backoff for retries",
             "body": "On 429/5xx, retry with backoff: wait 2**attempt seconds + "
                     "jitter, cap at 60s, give up after 5 tries. Jitter stops "
                     "retries from synchronizing. Never retry 4xx except 429."},
    "n107": {"title": "Caching layer design",
             "body": "Read-through cache, 5-min TTL, key on the normalized query. "
                     "Invalidate on write. Beware the stampede when a hot key "
                     "expires — use a lock or serve-stale-while-revalidate."},
    "n153": {"title": "Rate limiting",
             "body": "Token bucket beats fixed window. Return 429 + Retry-After so "
                     "clients back off deterministically. Limit per API key, not IP."},
}

def search_notes(query: str) -> str:
    words = [w for w in query.lower().split() if len(w) > 2]
    hits = [f"{nid}: {n['title']}" for nid, n in NOTES.items()
            if any(w in (n["title"] + " " + n["body"]).lower() for w in words)]
    return "\n".join(hits[:5]) if hits else f"No notes matched {query!r}."

def read_note(note_id: str) -> str:
    n = NOTES.get(note_id)
    return f"{n['title']}\n\n{n['body']}" if n else f"No note with id {note_id!r}."

IMPL = {"search_notes": search_notes, "read_note": read_note}

TOOLS = [
    {
        "name": "search_notes",
        "description": (
            "Search the local notes database for a keyword. Use whenever the "
            "user asks about anything that might live in their notes. "
            "Returns up to 5 matching snippets with note ids."
        ),
        "input_schema": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
    {
        "name": "read_note",
        "description": "Read one note in full, by id from search_notes results.",
        "input_schema": {
            "type": "object",
            "properties": {"note_id": {"type": "string"}},
            "required": ["note_id"],
        },
    },
]

def run_agent(question: str, max_iterations: int = 10) -> str:
    messages = [{"role": "user", "content": question}]
    for _ in range(max_iterations):
        resp = client.messages.create(
            model=MODEL, max_tokens=2048,
            tools=TOOLS, messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return resp.content[0].text          # model chose to stop

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = IMPL[block.name](**block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})
    raise RuntimeError("max iterations exceeded")   # we'll fix this in lesson 4

print(run_agent("What did I write about backoff?"))
Everything above run_agent is one-time setup — the key, a fake notes DB, and the tool implementations — so the cell actually runs in Colab. The agent is only the loop. Read its body slowly: it's the same four-step dance from Module 1, just repeated, and notice what's absent — no if/else deciding whether to search first or read first. The model sequences the tools itself by reading the schemas and the accumulating results. The for instead of while True is your first guardrail; raising on exhaustion is bad manners we'll replace with graceful degradation in lesson 4.
Key insight
Memorize this shape: while not done: response = llm(messages + tools); if tool_calls: execute, append results; else: done. Everything else in agent engineering is guardrails around this loop — termination, budgets, context discipline, tracing, recovery. When a framework shows you an 'AgentExecutor', this loop is what's inside.
Spot the bug
The loop above ships, works for weeks — then you enable adaptive thinking on the model and every run crashes with AttributeError: 'ThinkingBlock' object has no attribute 'text'. Where's the latent bug?
python
if resp.stop_reason != "tool_use":
    return resp.content[0].text          # model chose to stop

The inner loop lives inside an outer conversation

A distinction that sounds pedantic until an interviewer probes it: the agent loop runs entirely within one user turn. The user asks a question; your loop makes N model calls (each a full stateless request!); the user sees one answer. When they ask a follow-up, you append it to the same messages array — tool calls, results, and all — and the inner loop starts again with that history as context. Two design consequences: the follow-up turn inherits every token of the previous turn's tool spelunking (context cost compounds across user turns, which is why Lesson 5's compaction exists), and your termination budgets (Lesson 4) should be per user turn, not per conversation — a fresh question deserves a fresh budget.

Watch the path emerge

instrument the loop and the dynamic path becomes visible
# Colab cell 2 — run the setup cell above first (it defines client,
# MODEL, TOOLS, IMPL). This just adds print() calls to the same loop.
def run_with_trace(question: str, max_iterations: int = 10) -> str:
    messages = [{"role": "user", "content": question}]
    for i in range(max_iterations):
        resp = client.messages.create(
            model=MODEL, max_tokens=2048, tools=TOOLS, messages=messages,
        )
        if resp.stop_reason != "tool_use":
            print(f"[{i}] final answer after {i} tool iterations")
            return resp.content[0].text

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                print(f"[{i}] model chose: {block.name}({block.input})")
                output = IMPL[block.name](**block.input)
                print(f"[{i}]   -> {len(output)} chars back")
                results.append({"type": "tool_result",
                                "tool_use_id": block.id, "content": output})
        messages.append({"role": "user", "content": results})
    raise RuntimeError("max iterations exceeded")

print(run_with_trace("What did I write about backoff?"))
print("---")
print(run_with_trace("Summarize my notes on rate limits and caching"))

# A typical run prints something like:
# Run 1: "What did I write about backoff?"
#   [0] model chose: search_notes({'query': 'backoff'})
#   [1] model chose: read_note({'note_id': 'n042'})
#   [2] final answer after 2 tool iterations
#
# Run 2: "Summarize my notes on rate limits AND caching"
#   [0] model chose: search_notes({'query': 'rate limits'})
#   [0] model chose: search_notes({'query': 'caching'})     # parallel!
#   [1] model chose: read_note({'note_id': 'n153'})
#   [1] model chose: read_note({'note_id': 'n107'})
#   [2] final answer after 2 tool iterations
Run this yourself and watch the two runs take different paths through the same code — that's the agent-ness. Two things to internalize: the path differs per question with zero code changes, and the model may request multiple tool calls in a single turn — your executor must answer every one of them, and can run them concurrently since they arrived together. Your exact ids and iteration counts will vary run to run; the shape won't.
Predict the output
Run 3: the user asks "What did I write about Kubernetes?" — but there are no Kubernetes notes at all, so search_notes returns an empty list. Predict the plausible trajectories through the loop, from best to worst.
Interview angle
"What is an agent?" deserves a one-sentence answer with teeth: an LLM calling tools in a loop, where the model — not your code — decides the next action. Then immediately name the price of that autonomy: unknown iteration count → unknown cost, latency, and a new failure surface (spirals, runaway spend, hallucinated grounding). Interviewers are listening for whether you volunteer the costs unprompted; the definition alone is the junior half of the answer.
This loop is the job description
Across 2026 agentic job postings, "design agents that autonomously plan, call tools, and complete multi-step tasks" is the single most recurring responsibility line — and the market behind it is real: AI Engineer is LinkedIn's #1 fastest-growing US job title for 2026, with the Agentic AI skill cluster up roughly 280% year over year (~90K US postings). Being able to write this ~40-line loop from memory, in either provider's SDK, is the table stakes those postings are describing; the guardrails in lessons 4–5 are what make it a senior answer.

Whiteboard drills

Check yourself
Drill: Your team's chatbot answers from a fixed RAG pipeline today. Product wants it to 'become an agent.' What actually changes in the code, and what new failure modes must you handle before shipping?
Check yourself
Drill: In the traced Run 2, the model issued two search_notes calls in one turn, then two read_note calls the next turn. An interviewer asks: "why didn't it issue all four at once, and what does that tell you about parallelism in agent loops?"
Key takeaways
  • Agent = LLM + tools + loop, with the model choosing the path. Chatbot/workflow = your code chooses.
  • The loop is: call model → if tool_use, execute and append results → repeat → else return the text.
  • The model can emit several tool calls per turn — answer all of them; they're safe to parallelize. Cross-turn sequencing is the model's data-dependency discovery — don't fight it.
  • Extract the final answer by block type, never by position — content[0].text breaks the day thinking blocks appear.
  • Empty tool results invite hallucination — return what does exist, not [].
  • The agent loop runs inside one user turn; budgets are per-turn, and context inherited across turns is why compaction exists.
  • Flexibility costs you: unknown iteration count means unknown cost, latency, and new failure modes.
  • Everything that follows in this module is guardrails bolted onto this one loop.