Module 5 · Day Five
Architecture & Agents
A survey of patterns specific to LLM apps
Lecture
What you'll learn in this module

This is a survey — small examples of each pattern, no deep dives. The goal is vocabulary and shape recognition, so you can navigate the space when you build things later.

Patterns we'll cover
  • The agent loop as the foundation
  • What Claude Code actually is, architecturally
  • Subagents — recursion for agents
  • Memory scopes — what persists, where
  • Multi-model routing
  • Prompt caching for static prefixes
  • Long-running & async tasks
  • Computer use (vision agents)
  • Safety / permissions in agents
Vocabulary
  • Agent, agentic loop, harness
  • Subagent, Task tool, context isolation
  • Tool registry, dispatcher
  • Permission gate, hook
  • Computer use, vision tool
  • Router, fallback, ensemble
  • cache_control: ephemeral
Lecture
The agent loop — the engine

Every "agentic" system you've heard of has this loop at its core. The differences are what's wrapped around it.

def agent(messages, tools):
    while True:
        resp = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system="...",
            messages=messages,
            tools=tools,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason == "end_turn":
            return resp                              # we're done

        results = []
        for block in resp.content:
            if block.type == "tool_use":
                out = dispatch(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": out,
                })
        messages.append({"role": "user", "content": results})

~20 lines. Everything else in this module is a layer that sits above this loop or wires more tools into it.

Lecture
What is Claude Code, actually?

Claude Code (and tools like it — Cursor, Aider, Cline, Zed agent) are the agent loop above plus a thick stack of supporting machinery.

User interface
CLI · IDE extensions · slash commands · plan mode · status display
Permission & hooks
per-tool gating · auto-accept policy · PreToolUse / PostToolUse hooks for safety + logging
Tool registry
~25 built-ins (Read, Edit, Bash, Grep, Task, …) + MCP servers
Agent loop
Claude API messages + tools + stop_reason — the engine from the previous slide
Subagents
Task tool spawns a child Claude with its own context window; returns a summary
Memory
project (CLAUDE.md) · user (~/.claude/) · conversation (current session) · context compression

"Claude Code is a tool-use loop with a thick harness." The same recipe applies to building your own agentic app — you don't need to invent it from scratch.

Lecture
Subagents — recursion for agents

A subagent is just another Claude conversation, started by the parent, with its own context window, tool set, and system prompt. The parent gets back a single summary.

# Inside the parent agent's loop, the model decides to call the Task tool:
{
  "type": "tool_use",
  "name": "Task",
  "input": {
    "description": "Find all HTTP route definitions",
    "subagent_type": "explore",
    "prompt": "Search the codebase for Flask/FastAPI/Express route decorators.
               Return a list of (file, line, route) tuples. Don't summarise the
               implementations — just the routes."
  }
}
# The harness runs a brand-new Claude session with that prompt + the
# Explore agent's tools. Parent receives only the final report.
Why use them
· Context isolation — the parent doesn't see the 40 files the explorer read
· Specialization — each subagent type has a tuned system prompt + restricted tools
· Parallelism — multiple subagents can run concurrently
· Recursion — subagents can call subagents (use with care; budgets matter)
Lecture
Memory — what persists, where

"Memory" in LLM apps isn't one thing — it's several different scopes with different lifecycles, storage, and retrieval strategies.

Scope Lifetime Stored where Example
Conversation Current session In-process / Redis The message array on every turn
Session Until user logs out Server-side store Current open files, selected scope
User Across sessions DB or files keyed by user "Larry prefers terse answers"; saved preferences
Project While project exists Repo (e.g., CLAUDE.md) Coding conventions, file map, key paths
World Indefinite Vector store / RAG Indexed documents, web data, knowledge bases

A serious agent uses several at once. Claude Code reads CLAUDE.md (project), ~/.claude/memory/*.md (user), and the running session (conversation) on every turn — three scopes, one composite prompt.

Lecture
Multi-model routing

Use a cheap model to decide what to do; an expensive model to do it. Bound cost without giving up quality on the calls that matter.

def route(task: str) -> str:
    # 1. Cheap classifier — Haiku, no thinking, low effort
    kind = call(
        model="claude-haiku-4-5",
        system="Classify the task as trivial / reasoning / general.",
        user=task,
        schema=TaskKind,
        thinking=False, effort="low",
    )

    # 2. Route the actual work to the right model
    if kind == "trivial":
        return call(model="claude-haiku-4-5", user=task)
    if kind == "reasoning":
        return call(model="claude-opus-4-7", user=task, effort="high")
    return call(model="claude-sonnet-4-6", user=task)
When this pays:
  • High volume + variable difficulty (lots of cheap calls, occasional expensive ones)
  • Latency-sensitive paths (route obvious things to fast model)
  • Cost ceilings (cap the % of calls that hit the expensive model)

"Route quality where it matters; route speed where it doesn't." Also works with fallbacks (primary fails → try secondary) and ensembles (run two, pick best).

Lecture
Prompt caching — pay once for the static prefix

Long, static system prompts (rules, schemas, examples, retrieved context) get expensive when called often. Anthropic's prompt cache marks a prefix as cacheable; repeated calls with the same prefix read from cache at ~10% the cost.

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=512,
    system=[{
        "type": "text",
        "text": LONG_SYSTEM_PROMPT,             # 4KB+
        "cache_control": {"type": "ephemeral"}, # cache it
    }],
    messages=[{"role": "user", "content": user_query}],
)

# First call:
#   usage.cache_creation_input_tokens = 8200
#   usage.cache_read_input_tokens     = 0

# Next call within 5 min, same system prefix:
#   usage.cache_creation_input_tokens = 0
#   usage.cache_read_input_tokens     = 8200   # ~90% cheaper
When it pays
  • Long static system prompts repeated across many calls (rules + examples + schemas)
  • RAG: cache the retrieved context for follow-up questions on the same chunks
  • Agentic loops: each turn re-sends the same tool definitions — cache them

Min cacheable prefix ~4096 tokens. Cache lives 5 minutes (sliding window — every read extends it). Static rules + schemas go in the cached prefix; the dynamic question goes in messages. Pairs naturally with multi-model routing — cache the part that doesn't change, route what does.

Lecture
Long-running & async tasks

Sometimes the work doesn't fit in one HTTP request. The agent needs an out-of-band way to track progress.

Patterns
  • Background jobs — submit, get job_id, poll for status
  • Subprocess monitoring — start a long shell process; agent watches stdout
  • Scheduled wake-ups — agent says "wake me in N min"; server reschedules
  • Webhooks / callbacks — external event resumes the agent
  • Batch APIs — Anthropic + others offer 50% discount for non-urgent batched calls
Sketch
# Agent kicks off work it can't wait on
job_id = submit_task(
    prompt="Analyse this 2GB log file",
    callback_url="https://my.app/agent/resume",
)
return f"Started job {job_id}, will resume when done."

# Later, external worker finishes and POSTs back:
@app.route("/agent/resume", methods=["POST"])
def resume():
    result = request.json["result"]
    continue_agent_with(result)

Stateful agents need a way to pause and resume. The architecture choice is: where does the state live while the agent is paused?

Lecture
Progress messages — don't go silent

A long operation that prints nothing reads as hung. Have the worker emit progress events as it goes, and stream them to the user. This is exactly what makes Claude Code feel alive — "Reading 12 files…", "Running tests…".

Principles
  • Emit early and often — one event per meaningful step, not just at the end
  • Human-readable — say what's happening ("Calling search_docs…"), not just a spinner
  • Decouple producer from transport — the worker yields events; the caller decides how to surface them (SSE, websocket, log)
  • Always send a terminal event — a done or error so the client knows to stop waiting
Caller — stream each event (SSE)
@app.route("/agent")
def agent():
    def stream():
        for ev in run_agent(request.args["q"], TOOLS):
            yield f"data: {json.dumps(asdict(ev))}\n\n"
    return Response(stream(),
                    mimetype="text/event-stream")
from dataclasses import dataclass, asdict
from typing import Iterator

@dataclass
class Progress:
    step: str          # shown to the user
    done: bool = False

def run_agent(user_msg, tools) -> Iterator[Progress]:
    messages = [{"role": "user", "content": user_msg}]
    while True:
        resp = client.messages.create(
            model="claude-sonnet-4-6", max_tokens=1024,
            tools=tools, messages=messages,
        )
        messages.append({"role": "assistant",
                         "content": resp.content})

        calls = [b for b in resp.content
                 if b.type == "tool_use"]
        if not calls:                  # model is done
            yield Progress("Done", done=True)
            return

        results = []
        for call in calls:
            yield Progress(f"Calling {call.name}…")  # ← before the slow part
            output = dispatch(call.name, call.input)
            results.append({"type": "tool_result",
                            "tool_use_id": call.id,
                            "content": output})
        messages.append({"role": "user", "content": results})

Emitting before each tool call is what lets the user watch the agent's reasoning unfold. The same generator works for any long operation — an indexing job, a batch run, a shell process — and never knows whether it's feeding a CLI, a web UI, or a log file. That's the point.

Lecture · Computer use (1 of 2)
The computer tool — the schema

It's a built-in (server-defined) tool. You don't write its input_schema — you pick a versioned type and pass your screen size. The model then emits action calls.

How you declare it
tools = [{
    "type": "computer_20250124",  # versioned
    "name": "computer",
    "display_width_px": 1280,
    "display_height_px": 800,
    "display_number": 1,
}]
# beta header on the request:
# "computer-use-2025-01-24"

That's the whole declaration — three numbers. Anthropic owns the rest of the contract, so the model already knows every action and its parameters.

Actions the model can emit
screenshot left_click right_click double_click mouse_move left_click_drag type key hold_key scroll wait cursor_position
A returned call carries
{ "action": "left_click",
  "coordinate": [612, 348] }   # also: text,
# scroll_direction, scroll_amount, duration …
Lecture · Computer use (2 of 2)
The execution loop — your app drives everything

Claude never touches your machine. It only decides. Your harness owns the environment, captures the screen, and performs each action.

Environment
Container / VM
Linux desktop on a virtual display, with the target app running. Your app launches & owns it.
① screenshot (PNG)
→——————→
←——————←
④ execute (xdotool)
Your app / harness
The orchestrator
Runs the loop: grab screenshot → call the API → receive an action → perform it on the environment.
② image + messages
→——————→
←——————←
③ tool_use: action
Anthropic API
Claude (vision)
Sees the screenshot, decides the next action, returns a tool_use block. Never executes it.
↻ repeat every turn until Claude stops calling the tool
while not done:
    img = capture(vm)                  # ① you
    resp = client.messages.create(   # ②
        tools=[computer_tool],
        messages=msgs + [user_image(img)])
    for b in resp.content:           # ③ Claude
        if b.type == "tool_use":
            perform(vm, b.input)     # ④ you
Reference container: Anthropic ships a Docker image (Linux + a Python tool wrapping xdotool) so you don't build steps ① & ④ from scratch.
Costs: slow (a screenshot per turn), vision-token heavy, brittle to UI change. If an API or accessibility tree exists, use that instead.
Lecture
Safety in agents — permissions & limits

Agents that can act need different safety thinking than chatbots that can only talk. Two patterns dominate.

Permission gates

Each tool call is mediated. Categories of policy:

  • Always allow — read-only ops, safe by definition
  • Confirm — destructive ops (file delete, git push, API mutation)
  • Sandbox — bash inside a container, network restricted
  • Never — hard-coded refusals (delete prod, exfiltrate keys)

Claude Code, Cursor, Cline all expose these as user-tunable policies.

Hard limits
  • Step budget — max iterations of the loop
  • Token budget — kill the agent if it crosses N tokens
  • Time budget — wall-clock deadline
  • Tool-call rate — circuit-break on repeated identical calls (loop detection)
  • Scope — limit which files / endpoints / hosts the agent can touch

Agents fail interestingly. The risk isn't usually one disastrous tool call — it's 10,000 small ones that quietly burn budget, fill logs, or thrash on a bad assumption. Hard limits catch the latter.

Lecture
A Complete Agent

You now have vocabulary and shape for:

The agent loop

Tool calls in a loop until end_turn. ~20 lines.

Claude Code

Loop + tools + subagents + permissions + memory + MCP.

Subagents

Recursion. Context isolation. Specialization. Parallelism.

Memory scopes

Conversation / session / user / project / world.

Routing

Cheap-decides + expensive-executes. Bound cost without losing quality.

Prompt caching

Cache the static prefix. ~90% cheaper on repeat calls.

Long-running

Background, callbacks, batch. Where does state live while you wait?

Safety

Permission gates + hard limits. Catch the 10,000 small failures.

You can build a capable agent with everything above. But shipping it to real users takes more than architecture — you have to know it works and keep it safe. That's next.

Lecture
The agentic loop, drawn

The same ~20 lines as a picture: call the model, branch on stop_reason, run the tools, feed results back, repeat.

append tool_result → new user turn end_turn tool_use Task user message Tool definitions name · description · schema Claude messages.create() stop_ reason? Return final answer Execute tool calls dispatch(name, input)

Subagents, memory, routing, and caching are all layers wrapped around this loop.

Architecture & Agents
Project
Build a data-analyst agent
Project
A data-analyst agent

The agent loop from this module, wired to three read-only tools over a SQLite database. It runs as an interactive, multi-turn session — like this prompt — so follow-ups keep the context.

Starter project agent-app.zip
you › Top product by revenue last quarter?
  → list_tables() → describe_table(order_items) → run_query(SELECT …)
  Robot Vacuum — $46,307 (most recent quarter, completed orders).

you › What about the quarter before that?  # ← follow-up uses the history
  → run_query(SELECT …)
  Robot Vacuum again — $29,637, so revenue jumped ~56%.
What's in the zip
  • agent.py — the ~20-line loop + a CLI
  • tools.pylist_tables, describe_table, run_query
  • build_db.py + data.db — a seeded store (~3k orders, 18 months)
Your job (pick 2)
  1. Add a tool — e.g. monthly_trend or save_report; register it in TOOLS + dispatch.
  2. Probe the limits — find a question it gets wrong or answers inefficiently; show the transcript.
  3. Tighten the loop — stream progress, add loop detection, or surface the final SQL.
  4. Swap the data — point build_db.py at a different schema; the agent should work unchanged.

Spot the two safety patterns from this module already in place: read-only by construction (the query tool opens the DB in mode=ro, so SQLite rejects any write) and a hard step budget (MAX_STEPS) so a confused agent can't loop forever.

Beyond this project
Same agent, as a Claude Managed Agent

Our project is a tool-use loop you own. Managed Agents (beta) flips it: Anthropic runs the loop and hosts the workspace; you define a persisted agent and react to an event stream.

This project — Messages API + tool use Managed Agents
The loop You write the ~20-line while loop Anthropic's orchestration layer runs it
Multi-turn state Your messages list The session — stateful server-side (free)
Tools run On your machine (tools.py) In a hosted container — or host-side as custom tools
Config Inline in the script A persisted, versioned Agent + a Session per run
You consume A function's return value An SSE event stream

"The agent loop is ~20 lines; everything else is harness." Managed Agents is Anthropic shipping the harness. For a self-contained CLI, the loop you own is the right call — reach for Managed Agents when you want hosted execution, versioned configs, file mounts, or server-managed sessions.

Beyond this project
The same tools — Anthropic runs the loop

Keep tools.py exactly as-is (read-only run_query and all). Declare the three tools as custom, and our dispatch() moves from a while loop into an event handler — the DB never leaves your machine.

from tools import dispatch          # the SAME read-only tools.py, unchanged

# ── once: persist a versioned agent (model/system/tools live here, not on the session) ──
agent = client.beta.agents.create(
    name="Data Analyst", model="claude-sonnet-4-6",
    system="Inspect the schema before querying…",
    tools=CUSTOM_TOOLS,             # list_tables / describe_table / run_query, each type:"custom"
)

# ── per run: no while-loop — react to the event stream ──
session = client.beta.sessions.create(agent=agent.id, environment_id=env.id)
with client.beta.sessions.events.stream(session_id=session.id) as stream:
    client.beta.sessions.events.send(session_id=session.id, events=[
        {"type": "user.message", "content": [{"type": "text", "text": question}]}])
    for event in stream:
        if event.type == "agent.custom_tool_use":
            out = dispatch(event.name, event.input)     # ← our tools, our machine, our mode=ro gate
            client.beta.sessions.events.send(session_id=session.id, events=[
                {"type": "user.custom_tool_result",
                 "custom_tool_use_id": event.id,
                 "content": [{"type": "text", "text": out}]}])
        elif event.type == "session.status_idle" and event.stop_reason.type != "requires_action":
            break

Multi-turn is now free — the session is stateful server-side, so the messages list we hand-maintained disappears. (Alternatively: mount data.db as a file and let the built-in toolset query it inside Anthropic's sandbox — where the read-only file mount replaces our mode=ro gate.)

Up next
Production Considerations
Eval & testing, guardrails & security — making it trustworthy