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.ts | What it does | Built in |
|---|---|---|
1. TOOLS + runTool | Schemas describing 5 functions to the model, and the functions themselves | Lectures 02–03 |
2. GATED + preview + confirm | y/n gate before destructive actions | Lecture 04 |
3. callModel | The one function that talks to the Anthropic API | Lecture 05 |
4. agentLoop | The loop: call model → run tools → repeat | Lecture 05 (boss level) |
5. main | The readline REPL that drives it all | Lecture 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:
- 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_filewithpath: "foo.js"." It doesn't know whatread_filedoes — 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. - 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:
{ type: "text", text: "..." }— the model talking to the human.{ type: "tool_use", id: "toolu_01A...", name: "read_file", input: { path: "foo.js" } }— the model asking the harness to run something. Note it has anid— a unique tag for this specific request, generated by the model/API, not by you.{ type: "tool_result", tool_use_id: "toolu_01A...", content: "...", is_error: false }— the harness's answer to one specifictool_use, tagged by matchingtool_use_idback to the request'sid.
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 inmessagesthe next time you call it. RunningrunTool()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.
- How many entries are in
messagesat the exact momentcallModelis invoked for the second time (right before that call, not after)? messages[2]andmessages[4]both haverole: "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?messages[1].content[1].idis"toolu_01A". What later entry references that exact string, and under what field name?- Suppose
runTool("edit_file", ...)had thrown an error instead of succeeding (say,old_stringmatched zero times). Based on what you know about tool_result blocks having anis_errorfield, what wouldmessages[4]most plausibly contain instead, and would the loop stop or continue? messages[1]contains both atextblock and atool_useblock in the same array. Is that a special case, or does it follow directly fromcontentbeing defined asContentBlockParam[]?- If you deleted
messages[2]before call #2 — i.e. the model'stool_userequest inmessages[1]never got an answer — what would you expectcallModelcall #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:
tsxis a tool that runs.tsfiles directly —npx tsx index.ts— without a separate compile step. Node itself can't execute TypeScript's type annotations natively;tsxstrips/transpiles on the fly, the same waypython3 foo.pyjust runs Python without a "build" phase. You will not touchtscor any bundler in this course — that's explicitly out of scope (see "What we skipped" below).vitestis the test runner (think: a faster, TypeScript-native analog ofpytest)../okis a ~10-line bash wrapper aroundnpx vitest run../ok 00runs onlytests/00_*.test.ts; bare./okruns everything.
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 hardcodereturn 61001;— it works and passes the test, but the whole point of61 * 1000 + 1is 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
- 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
runToolreturns as a string.
messagesis 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.
- 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.
tool_use.idandtool_result.tool_use_idmust 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.
- The loop's termination condition is structural, not semantic: no
tool_useblocks 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.
- This course builds one file, five sections, in dependency order.
TOOLSandGATEbeforeMODEL;MODELbeforeLOOP;LOOPbeforeREPL. Each lecture fills exactly one section, using the exact identifiers already declared in the skeleton.
What we skipped (for future study or future lectures)
- Streaming responses (the loop here always waits for a complete
Messagebefore acting). - What actually happens inside
preview/confirm— that's lecture 04; today you only saw that a gate exists in the trace, not how it's implemented. - The internals of
runToolfor each of the 5 tools — lectures 02–03. - Anything about
tsc, bundlers, or TypeScript build tooling —tsxrunning files directly is all this course uses. - Multi-step planning, subagents, context compaction — real Claude Code has all of these; this course's whole point is that the core loop needs none of them to work.
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
- 3.
messages=[0](your request),[1](assistant + tool_use),[2](tool_result) — three entries exist right before call #2 fires. - 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. messages[3].content[1].input.old_string? No — trick to watch for."toolu_01A"is referenced bymessages[2].content[0].tool_use_id, thetool_resultanswering that specific request."toolu_01B"(a different id, from theedit_filecall inmessages[3]) is whatmessages[4].content[0].tool_use_idmatches. Don't confuse the two ids — eachtool_usegets its own.messages[4]would contain atool_resultwithis_error: trueandcontentset 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 correctedold_string. This is the self-correction behavior lecture 05's tests specifically script for.- Follows directly, not a special case.
contentis typed asContentBlockParam[], an array — nothing restricts it to one block. A single assistant turn commonly narrates intextand requests one or moretool_usecalls in the same turn; the loop just iterates every block inresponse.content. - The API would reject the request (a 400-style error) — Anthropic's API requires every
tool_usein the prior assistant turn to have a matchingtool_resultbefore 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), somain()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.