The Shape of an Agent


Lecture Overview

By the end of this course you'll have built a working ~350-line clone of Claude Code in a single file, index.ts, and run it for real against a planted bug. Underneath, an agent is just an append-only array of messages and a loop that keeps a model and a handful of functions talking until the model stops asking for help. There's no planner and no hidden reasoning engine — the intelligence lives in the model, and everything you build is the harness that lets it read files, write files, and run shell commands on your machine.

This first lecture builds none of that code. Its job is to lay out the full picture before you write anything: what a tool_use block is, why a tool's result comes back as a "user" message, and what one complete agent conversation looks like on the wire. One warm-up bug fix, and a close read of a real transcript until its shape is obvious.

What this lecture builds toward (the whole course, so you can see the shape of where you're headed):

Section of index.tsWhat it doesBuilt in
1. TOOLS + runToolSchemas describing 5 functions to the model, and the functions themselvesLectures 02–03
2. GATED + preview + confirmy/n gate before destructive actionsLecture 04
3. callModelThe one function that talks to the Anthropic APILecture 05
4. agentLoopThe loop: call model → run tools → repeatLecture 05 (boss level)
5. mainThe readline REPL that drives it allLecture 06

Today you'll read all five sections of the skeleton without writing most of them — only one two-character bug fix, warmup(), is fair game right now.

How this lecture works: Every section ends with a Checkpoint and a pointer into the Appendix; try each task — usually "predict what happens," not "write code" — before you look. The one exception is Problem 0, real code checked by ./ok 00. This lecture is worth 1 of the course's 10 points, earned by Problem 0 passing plus the six unlock questions matching the answer key.


1. What Is an Agent?

Concept

Strip away the marketing and an "agent" is two things wired together:

  1. A model that can ask for things. Instead of only returning text, the model can return a request shaped like a function call: "call read_file with path: "foo.js"." It doesn't know what read_file does — it only knows the name and the shape of the arguments, from a schema you gave it. This is your familiar interface: a name, a signature, and a contract, with no implementation attached.
  2. A harness that actually runs things. Something on the your side of the wire reads that request, executes the real function (opens the real file, on the real disk), and reports back what happened. The model never touches your filesystem directly — it only ever sees text you choose to show it.

Loop those two together — model asks, harness runs, harness reports back, model asks again — and you have an agent. That loop is the entire "trick." Everything else in this course (tool schemas, the permission gate, error handling) is detail around that loop, not a replacement for it. The loop ends when the model's turn contains no tool_use blocks — there is no separate "I'm done" signal.

Why It Matters

If you've only used Claude Code as an end user, its behavior can feel like it's "thinking" in some deep, opaque way about your codebase — planning multi-step refactors, deciding what to check next. It isn't planning in any special sense. It's calling a model repeatedly, feeding back whatever the model most recently asked to see. Every "decision" you observe (read this file, then that one, then edit this line) is the model choosing its next move given everything that's happened so far, with no separate planning subsystem bolted outside the loop.

Understanding this precisely is what lets you debug a real agent later: if it does something wrong, the bug is in one of exactly three places — the tool's description (the model misunderstood the contract), the tool's implementation (runTool did the wrong thing), or the loop (results weren't fed back correctly). There is no fourth place to look.

Your Task

No code yet. Answer this before reading on: A model, on its own, decides to call bash with {"command": "rm -rf fixtures/"}. What happens to your filesystem right now, at the instant the model produces that request?

Think about it in terms of the two-part definition above before you check the Checkpoint.

Checkpoint

Nothing happens yet. The model asking for a tool call is just text in a structured shape — it is inert until something on the harness side chooses to execute it. This is exactly why lecture 04 exists: the harness gets to look at that request, show it to a human, and refuse. The model proposes; the harness (you, or your permission gate) disposes.

Stuck? See Snippet 1 in the Appendix.


2. The Message Protocol as a Data Structure

Concept

Everything the model has ever seen or said lives in one value: messages, a Anthropic.MessageParam[] — an array. Not a tree, not a graph with branches — a flat, append-only array. Every turn appends one or more entries. Nothing is ever edited or removed in place (lecture 06's crash recovery pops a bad entry off the end — even the one exception is a stack operation, not a mutation).

Each entry (MessageParam) has two fields:

{ role: "user" | "assistant", content: string | ContentBlockParam[] }

(the | means "either/or" — e.g. role is either "user" or "assistant", and content is either a plain string or an array of blocks)

Only two roles exist. "user" means "the harness said this" (your typed input, or a tool's result — more on that below). "assistant" means "the model said this." There's no third role for "the tool" or "the system" mid-conversation — the system prompt is passed separately, once, outside this array entirely (you'll see SYSTEM_PROMPT passed as its own argument in callModel, not woven into messages).

content is usually not a plain string once tools are involved — it's an array of content blocks, and a single turn can mix several:

That id / tool_use_id pairing is not cosmetic. If the model asks for three tools in one turn, you owe it three separate tool_result blocks, each carrying the matching tool_use_id — otherwise the model has no way to know which result answers which question, the same way you'd need a request ID to match async responses to their callers in any request/response system.

Why It Matters

This one fact — messages is a flat array, tool results ride back in as a "user" message — resolves almost every "wait, why is it shaped like that" moment you'll have while implementing agentLoop in lecture 05. It's easy to instinctively want a "tool" role, or to want to mutate a previous assistant message once its tool call resolves. Both instincts are wrong for this API, and both would break the model's ability to reconstruct what happened, because the model re-reads the entire array from scratch on every single call — it has no memory between calls except what's in messages. If you silently dropped or edited an entry, the model would be reasoning from a version of history that never happened.

This also explains why the harness, not the model, decides when the conversation is "done": the loop's termination condition is purely structural — does the most recent assistant turn contain any tool_use blocks? — not a signal the model has to explicitly emit.

Your Task

Predict, don't write code: given the two-content-block example above (a tool_use for read_file sitting alongside text), if the harness runs that tool and gets back the string "function foo() {...}", what does the next entry appended to messages look like — role and content shape, not full JSON?

Checkpoint

It's { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_01A...", content: "function foo() {...}", is_error: false }] }. If your answer said role: "tool" or put the result directly as a bare string for content, re-read the block shapes above — this is the single most common first-guess mistake, and section 6's unlock questions will test it again.

Common mistake: conflating "the tool ran" with "the model knows the tool ran." The model only knows what's in messages the next time you call it. Running runTool() and never pushing its result is a silent bug — the model will just repeat the same request forever, because as far as it can tell, it never got an answer.

Stuck? See Snippet 2 in the Appendix.


3. A Full Trace: Watching Nano Fix a Bug

Concept

This is the centerpiece of the lecture. Below is a complete, real conversation — the kind agentLoop will drive once you've built it — annotated line by line. The scenario: fixtures/convert.js (a file already sitting in this repo, which you'll use for real in lecture 06) has a bug — Celsius-to-Fahrenheit is computed with the fraction flipped:

function celsiusToFahrenheit(c) {
  return (c * 5) / 9 + 32; // BUG: should be (c * 9) / 5 + 32
}

We type: "There's a bug in fixtures/convert.js — the temperature conversion looks wrong. Can you find and fix it?"

Here is the entire messages array's evolution, one push at a time. callModel(messages) is called three times total; each call sees the entire array built so far.

messages = []                                         // REPL starts, empty log

──── We type our request; REPL pushes it ────
messages[0] = {
  role: "user",
  content: "There's a bug in fixtures/convert.js — the temperature
             conversion looks wrong. Can you find and fix it?"
}

──── callModel(messages) call #1 — model sees just [0] ────
response.content = [
  { type: "text", text: "I'll take a look." },
  { type: "tool_use", id: "toolu_01A", name: "read_file",
    input: { path: "fixtures/convert.js" } }
]
messages[1] = { role: "assistant", content: response.content }  // pushed VERBATIM

──── Harness executes the tool_use from [1] ────
runTool("read_file", { path: "fixtures/convert.js" })
  → "function celsiusToFahrenheit(c) {\n  return (c * 5) / 9 + 32; ...}"
  (read_file is NOT gated — no y/n needed, it can't change anything)

messages[2] = {
  role: "user",
  content: [{ type: "tool_result", tool_use_id: "toolu_01A",
              content: "function celsiusToFahrenheit(c) {...}",
              is_error: false }]
}

──── callModel(messages) call #2 — model sees [0, 1, 2] ────
response.content = [
  { type: "text", text: "Found it — the formula multiplies by 5/9
                          instead of 9/5. Fixing." },
  { type: "tool_use", id: "toolu_01B", name: "edit_file",
    input: { path: "fixtures/convert.js",
              old_string: "return (c * 5) / 9 + 32;",
              new_string: "return (c * 9) / 5 + 32;" } }
]
messages[3] = { role: "assistant", content: response.content }

──── Harness executes the tool_use from [3] ────
edit_file IS gated (lecture 04) — confirm() prints a preview and waits.
We type "y". runTool(...) applies the edit, returns "Edited fixtures/convert.js"

messages[4] = {
  role: "user",
  content: [{ type: "tool_result", tool_use_id: "toolu_01B",
              content: "Edited fixtures/convert.js", is_error: false }]
}

──── callModel(messages) call #3 — model sees [0, 1, 2, 3, 4] ────
response.content = [
  { type: "text", text: "Fixed — celsiusToFahrenheit now multiplies
                          by 9/5 before adding 32." }
]
messages[5] = { role: "assistant", content: response.content }

──── No tool_use blocks in [5] → loop returns. Control back to REPL. ────

Six entries. Three round trips to the model. One human "y". Nothing about this is more complicated than that — every real Claude Code session, however long, is this same pattern run many more times.

Why It Matters

Notice what the model never sees: your filesystem, the exit code convention of edit_file, whether the y/n prompt happened. It only ever sees the six entries above, as text. Every capability the model appears to have — "it read the file," "it understood the bug," "it fixed it" — is entirely mediated by what the harness chooses to put into messages[2] and messages[4]. Get the content of a tool_result wrong (truncate it, garble it, forget to include the error) and the model's next move gets worse, with no other cause. This is the leverage point you'll be implementing across lectures 02–05: the loop's shape is fixed and small; almost all of the engineering is what string does runTool return.

Your Task — Unlock Questions

Six questions on the trace above. Write down your answers before checking the Appendix — these are graded by comparison against the answer key, not by ./ok.

  1. How many entries are in messages at the exact moment callModel is invoked for the second time (right before that call, not after)?
  2. messages[2] and messages[4] both have role: "user" — but neither one is something a human typed. What is the rule for which role a message gets: is it "who is speaking," or something else?
  3. messages[1].content[1].id is "toolu_01A". What later entry references that exact string, and under what field name?
  4. Suppose runTool("edit_file", ...) had thrown an error instead of succeeding (say, old_string matched zero times). Based on what you know about tool_result blocks having an is_error field, what would messages[4] most plausibly contain instead, and would the loop stop or continue?
  5. messages[1] contains both a text block and a tool_use block in the same array. Is that a special case, or does it follow directly from content being defined as ContentBlockParam[]?
  6. If you deleted messages[2] before call #2 — i.e. the model's tool_use request in messages[1] never got an answer — what would you expect callModel call #2 to do: proceed normally, hang, or have the API reject the request?

Checkpoint

You should be able to answer all six from the transcript alone, with no outside knowledge. If any felt like a guess, re-read section 2 — every one of these questions is testing a claim made there.

Stuck? See Snippet 3 in the Appendix.


4. Tour of the Skeleton

Concept

Open index.ts. It's ~230 lines, and its own header comment already tells you the shape:

1. TOOLS   — 5 plain functions + JSON Schemas describing them to the model
2. GATE    — y/n confirmation before destructive tools
3. MODEL   — one callModel() hiding the backend
4. LOOP    — the agent loop (the whole trick)
5. REPL    — a plain readline prompt

That ordering isn't arbitrary — it's the dependency order. GATE is used by LOOP; MODEL is called by LOOP; LOOP is driven by REPL. Each later section only makes sense once the one above it exists, which is also the order this course builds them in (lectures 02–03 → 04 → 05 → 06).

Right now, almost every function body is a one-liner: throw new Error("YOUR CODE HERE"). That's deliberate — the type signatures, doc comments, and the TOOLS schema array are given, because reading a contract is the lesson; retyping JSON Schema by hand is not. What's stubbed is the implementation behind each contract.

index.ts is the only file you ever edit in this entire course. Everything else — tests/, fixtures/, solution/ — is read-only harness. solution/index.ts is the finished reference; peeking is allowed, but only after you've tried, or you'll just be transcribing instead of learning the shape.

Why It Matters

You are about to spend five lectures inside a single file. Knowing the section boundaries now means that when lecture 05 says "implement agentLoop," you already know exactly where that function lives, what it's allowed to call (runTool, confirm, callModel — sections 1–3, already built by then), and what calls it (main, not yet built). Skeleton tours matter more here than in a typical multi-file project, precisely because it's one file — there's no directory structure to lean on for orientation.

Your Task

Open index.ts and, for each of the 5 sections, note one thing: is its exported surface (function/type names) fully given, or does the section itself not exist yet? (Trick element: check whether Asker and TOOLS — used by later sections — are already fully written even though the functions that use them aren't.)

You'll see Asker defined roughly as { question(prompt: string): Promise<string> } — your first sight of Promise in this course. You'll see Promises/async explained properly in lecture 04 — for now read Promise<string> as "a function that eventually hands back a string."

Checkpoint

Asker (the type) and TOOLS (the schema array) are both fully written — no stubs. runTool, preview, confirm, callModel, agentLoop, and main are all throw new Error("YOUR CODE HERE") bodies behind fully-typed signatures. If you found yourself trying to edit TOOLS's contents to "fix" something, stop — nothing there is broken; it's the fixed contract every later problem builds against.

Stuck? See Snippet 4 in the Appendix.


5. Environment Setup

Concept

Two Node/TS-isms worth naming before you touch a terminal:

Why It Matters

Every problem in this course, from here through lecture 06, is checked the same way: ./ok NN. If npm install or Node itself is broken, nothing downstream works — this is why lecture 01's only machine-checked artifact is "does the environment even run," not "did you write good code." Get this green before worrying about anything else.

Your Task

From the claude-code-nano/ directory:

npm install
./ok 00

You should see it fail — that failure is expected right now; fixing it is Problem 0, next section. Here's the exact output the skeleton currently produces:

ok: running tests for problem 00

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 ❯ tests/00_setup.test.ts (3 tests | 1 failed) 5ms
     × Problem 0: warmup() must return 61001 (fix the off-by-two in index.ts) 3ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/00_setup.test.ts > 00 · environment sanity > Problem 0: warmup() must return 61001 (fix the off-by-two in index.ts)
AssertionError: expected 60999 to be 61001 // Object.is equality

- Expected
+ Received

- 61001
+ 60999

 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)

(output trimmed — the real run also prints Start at / Duration trailer lines; ignore those)

Two of the three tests already pass: Node version (≥ 20 — this environment ships Node 24) and "importing index.ts doesn't crash." Only the warmup() assertion fails. That's the whole job of Problem 0, next.

Checkpoint

npm install completes with no errors, and ./ok 00 runs (producing the failure above, not a crash or a "command not found"). If npm install itself errors, stop and fix that before reading further — Problem 0 assumes a working install.

Stuck? See Snippet 5 in the Appendix.


6. Problem 0: Fix the Warm-up (1 pt)

Concept

Near the top of index.ts:

// A tiny warm-up used by lecture 01. Fixing this trivial value is Problem 0 —
// the "does my environment run at all?" check.
export function warmup(): number {
  return 61 * 1000 - 1; // Problem 0: something is off by two...
}

export function warmup(): number is your first TypeScript-ism worth naming explicitly: export makes the function importable from other files (that's how tests/00_setup.test.ts gets at it — import { warmup } from "../index.ts"), and : number is a return-type annotation — a promise to the compiler that this function always hands back a number, checked at compile time rather than discovered at runtime like you'd get away with in plain JS.

The bug: 61 * 1000 - 1 evaluates to 60999. The test wants 61001 — 61 * 1000 + 1. The comment even tells you it's "off by two": 60999 to 61001 is a swing of 2, caused by one flipped operator.

Why It Matters

This problem carries zero conceptual weight on its own — it's not testing whether you understand agents. It's testing whether your toolchain works end to end: can you edit index.ts, save it, and have ./ok 00 pick up the change and go green? Every one of the next five lectures assumes yes. This is the "hello world" of the course, and — like a real hello world — the value isn't the code, it's confirming nothing between you and the test runner is broken.

Your Task

In index.ts, change warmup()'s body so it returns 61001. Don't touch anything else in the file — every other stub still legitimately throws, and that's correct for now (later lectures' tests won't even run against those functions yet).

Hint: You need to flip exactly one character (- → +). Resist the urge to hardcode return 61001; — it works and passes the test, but the whole point of 61 * 1000 + 1 is that it's legible as "61" and "001" mashed together, not a single magic constant. Prefer the arithmetic form.

Checkpoint

Run ./ok 00 again. Expected output:

ok: running tests for problem 00

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  3 passed (3)

All 3 tests green, 0 failed. That's Problem 0 done, and lecture 01 complete: environment confirmed working, Problem 0 passing, and (if you got them right) six correct unlock answers on the message protocol.

Stuck? See Snippet 6 in the Appendix.


Key Takeaways

  1. An agent is a model that can ask, plus a harness that runs and reports back. The model never touches your machine directly. Every capability you'll ever see it exhibit is mediated by what your runTool returns as a string.
  1. messages is one flat, append-only array — never a tree, never mutated in place. The model has no memory across calls except what's in this array; it re-reads the whole thing every single time.
  1. Only two roles exist, and "user" doesn't mean "human." It means "the harness said this" — which covers both what you typed and every tool_result, because tool results are the harness's turn, just like your typed input is.
  1. tool_use.id and tool_result.tool_use_id must match. This is the invariant that lets a model ask for several tools in one turn and still know which answer belongs to which question — the same discipline as matching async request IDs to their callers.
  1. The loop's termination condition is structural, not semantic: no tool_use blocks in the most recent assistant turn means "done." The model never emits an explicit "I'm finished" signal — you infer it from the shape of its last move.
  1. This course builds one file, five sections, in dependency order. TOOLS and GATE before MODEL; MODEL before LOOP; LOOP before REPL. Each lecture fills exactly one section, using the exact identifiers already declared in the skeleton.

What we skipped (for future study or future lectures)


Appendix: Reference Implementation

Check your work here after attempting each section. Don't read ahead — one snippet at a time.

Snippet 1: The two-part definition

An agent = (1) a model that can emit structured requests (tool_use blocks) instead of only prose, given a schema describing what it's allowed to ask for, plus (2) a harness that reads those requests and decides, on its own authority, whether and how to execute them. The model producing a tool_use block is inert text until the harness's loop (agentLoop, lecture 05) chooses to call runTool. For gated tools (bash, write_file, edit_file — lecture 04's GATED set), that choice runs through a human y/n first. Nothing happens to your filesystem merely because the model "wants" it to.

Snippet 2: Message shapes
// A plain user turn (what the REPL pushes for your typed input):
{ role: "user", content: "fix the bug in foo.js" }

// An assistant turn with mixed content — pushed VERBATIM from response.content:
{
  role: "assistant",
  content: [
    { type: "text", text: "I'll check that file." },
    { type: "tool_use", id: "toolu_01A", name: "read_file", input: { path: "foo.js" } },
  ],
}

// The harness's answer to that tool_use — note role is "user", not "tool":
{
  role: "user",
  content: [
    { type: "tool_result", tool_use_id: "toolu_01A", content: "...file contents...", is_error: false },
  ],
}

The rule for role is purely "who owns this turn in the protocol," not "who is a human." The harness owns tool results the same way it owns your typed input — both are things the API's "assistant" (the model) did not produce.

Snippet 3: Unlock question answers
  1. 3. messages = [0] (your request), [1] (assistant + tool_use), [2] (tool_result) — three entries exist right before call #2 fires.
  2. Which "side of the wire" produced it, not "was a human typing." role: "user" means "the harness, in the broad sense" — your literal keystrokes and a tool's result are both the harness's turn to speak.
  3. messages[3].content[1].input.old_string? No — trick to watch for. "toolu_01A" is referenced by messages[2].content[0].tool_use_id, the tool_result answering that specific request. "toolu_01B" (a different id, from the edit_file call in messages[3]) is what messages[4].content[0].tool_use_id matches. Don't confuse the two ids — each tool_use gets its own.
  4. messages[4] would contain a tool_result with is_error: true and content set to the error message string (e.g. "old_string not found in fixtures/convert.js"). The loop does not stop — an error is still a result, just an unhappy one; the model sees it on call #3 and can retry with a corrected old_string. This is the self-correction behavior lecture 05's tests specifically script for.
  5. Follows directly, not a special case. content is typed as ContentBlockParam[], an array — nothing restricts it to one block. A single assistant turn commonly narrates in text and requests one or more tool_use calls in the same turn; the loop just iterates every block in response.content.
  6. The API would reject the request (a 400-style error) — Anthropic's API requires every tool_use in the prior assistant turn to have a matching tool_result before it will accept the next call. This is exactly the scenario lecture 06's crash-recovery code guards against: if a call fails mid-loop, the last assistant message is "dangling" (has unanswered tool_use blocks), so main() pops it before trying again.
Snippet 4: Skeleton map
TOOLS   — Anthropic.Tool[]                         GIVEN, fully written
runTool — export function runTool(name, input)     STUB  (lectures 02–03)
GATED   — Set<string>                               GIVEN
preview — export function preview(name, input)      STUB  (lecture 04)
confirm — export async function confirm(ask, ...)   STUB  (lecture 04)
callModel — export async function callModel(...)    STUB  (lecture 05)
agentLoop — export async function agentLoop(...)     STUB  (lecture 05)
main    — export async function main()               STUB  (lecture 06)

Asker   — export type Asker = { question(...) }     GIVEN (used by confirm/main)
warmup  — export function warmup(): number           STUB — YOUR job today
Snippet 5: Setup notes

npm install pulls three runtime deps (@anthropic-ai/sdk, @types/node, tsx) and one dev dep (vitest) — see package.json. No API key is required for anything in this lecture, or for ./ok in general: every test file scripts a fake model response instead of calling the real API (that's why callModel's model parameter in agentLoop is injectable — you'll see this explicitly in lecture 05). You only need ANTHROPIC_API_KEY once, for lecture 06's live acceptance run against the real API.

Snippet 6: Warmup fix
export function warmup(): number {
  return 61 * 1000 + 1; // 61001
}

One character changed: - to +. That's the entire diff for Problem 0.


End of lecture.