The REPL and the Endgame
Lecture Overview
Five lectures built five sections of index.ts, and none of them could run. runTool executes a tool, but nothing calls it without a model asking. confirm gates a dangerous action, but nothing gates anything without a loop driving it. agentLoop calls a model, runs tools, feeds results back, and stops when the model stops asking — but every test so far handed it scripted fake models and scripted fake humans. Today you build main(): a plain readline prompt that reads what you type, hands it to agentLoop, and prints whatever comes back, until you tell it to stop. main() itself is maybe fifteen lines, and every one of them calls something already built. The genuinely new material is narrow: one Node module you haven't touched (readline/promises), one array that finally lives somewhere with a real beginning and stops only at /exit, and one repair for a specific way a live conversation can break — one that no scripted test in this course ever needed to simulate, because it only happens when a real network call to a real API fails partway through a real turn.
Then the lecture stops testing you and starts watching you. Section 5 is the live acceptance run: you export a real ANTHROPIC_API_KEY, run your own index.ts against a real model, and watch it read fixtures/convert.js, find the inverted Celsius-to-Fahrenheit formula, ask your permission to fix it, and verify its own fix by actually running the script. This is the exact transcript you hand-traced, cold, with no code behind it, in lecture 01 §3 — today it's one your own ~350 lines of TypeScript produce.
How this lecture works: one numbered problem (6.1, in three parts — session state, the loop itself, crash recovery), each with a spec, a worked example, constraints from the actual test file, and unlock questions answered only in the Appendix — followed by the ./ok 06 checkpoint, the live acceptance run, a retrospective, and extra-credit challenges. This lecture is worth 1 of the course's 10 points, earned by ./ok 06 green plus its unlock answers — that's 10 out of 10; the live run is the real payoff and strongly encouraged, but it's a victory lap, not a scoring gate. One of the original extra challenges has since graduated into its own bonus lecture 07.
1. The Session State, and the One Line You're Not Allowed to Touch
Concept
Everything main() does happens around one array, declared exactly once, that holds the entire memory of a session: every line you type, every reply the model gives, every tool result the harness produces — the same Anthropic.MessageParam[] you traced by hand in lecture 01 §2 and fed to agentLoop under scripted control in lecture 05. Nothing else persists between the lines you type at the prompt. There's no hidden session object, no database, no second array tracking "context" on the side. If it isn't in that one array, the model has no way to know it happened. This is the same array lecture 01 described from day one, now the literal data structure sitting in main()'s body, created once, mutated by every turn, read from scratch by every call to the model.
To read what you type, main() needs an object that can pull one line at a time off the real keyboard and print prompts to the real terminal. Node's readline/promises module — the promise-returning sibling of the classic callback-based readline you may have seen in older Node code — exists for exactly this. It exports a single factory function: hand it the process's standard input and output streams, and it hands back an object wired up to read and write against a real terminal, with a method that asks a question and resolves a Promise<string> once a line arrives. Look at that signature again: a method that asks a question and resolves a Promise<string>. That's not a coincidence, and it's the payoff of a concept lecture 04 planted three lectures ago without letting you see it pay off yet.
Why It Matters
Recall Asker from lecture 04: { question(prompt: string): Promise<string> } — the narrowest possible interface confirm (and, since lecture 05, agentLoop) actually needs from whatever answers its yes/no questions. Lecture 04's tests satisfied that interface with a scriptedAsker — a plain object, built from scratch, with nothing to do with a real terminal, that happened to have a method with the right name and the right signature. Today you satisfy the exact same interface with the real terminal-backed object readline/promises hands you — and here's the payoff: you don't write a single line of adapter code to make that work. TypeScript's type system is structural, not nominal: a value satisfies a type if its shape matches — the right method names, the right parameter and return types — regardless of what class built it or what the value's author called it. Contrast this with a nominally-typed language (Java, C#), where you'd have to write class RealAsker implements Asker explicitly, by name, before the compiler would let you pass a real reader anywhere an Asker was expected. Here, the real interactive reader object just happens to have a question(prompt: string): Promise<string> method, and that's the entire proof TypeScript needs. This is the concrete, paid-off version of the abstract promise lecture 04 §3 made about Asker being "dependency injection in one method" — today you see the other side of that injection: not a fake standing in for the real thing in a test, but the real thing itself, accepted without a single cast, wrapper, or adapter class, purely because its shape matches.
The other piece already sitting at the very bottom of index.ts, unchanged since lecture 01, is this line — given, not yours to write or modify:
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
This is the main-module guard. Read it as a single question: "was this exact file the one directly executed on the command line (via npx tsx index.ts), or was it merely import-ed by something else (a test file, for instance)?" import.meta.url is this module's own URL; pathToFileURL(process.argv[1]) converts "the script Node was told to run" into that same URL format so the two are comparable apples-to-apples. Only when they match — only when index.ts is the thing you actually ran, not a file some other module pulled in — does the guard call main(). Every single test file in this course, in every lecture, does import { ... } from "../index.ts" at its top. If that import alone triggered main(), every test run in this entire course would immediately try to attach a live prompt to the test process's standard input, which isn't a real interactive terminal at all during a test run — and, worse, every one of agentLoop's scripted tests would suddenly be fighting a second, real, unwanted conversation loop nobody asked for. The comment sitting above this exact line in the skeleton says this directly: "importing this file for smoke checks or tests must stay side-effect free." That's the whole reason lecture 05's callModel had to construct its client lazily, and it's the same worry here, one layer up — the fix, this time, isn't laziness inside a function, it's a guard around the one statement in the whole file that would otherwise run unconditionally at import time.
Your Task
No code yet — two predictions.
First: tests/06_repl.test.ts — read it now if you haven't already — does exactly one thing to verify this guard works: it deletes ANTHROPIC_API_KEY from the environment, imports index.ts, and checks that a particular banner string was never printed to the console. Why is "the banner wasn't printed" good evidence that the guard is working, given that the banner is something main() prints near its own start, and main() is the only thing standing between "just importing the module" and "running the whole REPL"?
Second: suppose, hypothetically, the guard's condition were deleted entirely, so the line just read main(); unconditionally at the bottom of the file. Walk through what happens the instant any test file in this course — say, tests/00_setup.test.ts, the very first one — executes its own import { warmup } from "../index.ts" line.
Checkpoint
For the first: the banner is main()'s own first observable action — printed before the prompt loop even starts. If merely importing the file caused the banner to appear, that would be direct proof main() ran during import, which is precisely the side effect the guard exists to prevent. Its absence is the only observable signal available to a test that can't safely try to drive a real REPL (no terminal, no scripted input stream wired to it) — so the test settles for checking that the one thing main() would visibly do, if it ran, didn't happen.
For the second: every test file in this course imports index.ts at its top, including tests/00_setup.test.ts, which has nothing to do with the REPL at all — it only wants warmup(). Without the guard, that import alone would call main(), which would try to build a real readline interface against the test runner's own standard input (not an interactive terminal, and not fed any scripted lines) and then sit there awaiting the first line forever. Every test file's import statement — the very first line of the file, before a single it(...) block even runs — would hang indefinitely. ./ok would never produce output for any lecture, not just lecture 06; the entire test suite depends on this one guarded line staying exactly as written.
Stuck? See Snippet 1 in the Appendix.
2. Problem 6.1a: The Prompt Loop
Concept
main()'s job, stripped to its essence, is a while loop with four moves per lap: show a prompt and wait for one line of input; decide whether that line deserves a response at all; if it does, append it to the session's array as ordinary user input; and drive one full round of agentLoop on the strength of that new line, exactly the way you'd drive it from a scripted test — except the model, the human, and the tools are all real this time. Two of those four moves are filters, and getting them backwards (or skipping them) produces a REPL that either burns an API call on a blank keystroke or shuts down permanently the first time you were only trying to clear your screen.
Filter one — the blank line. A human sitting at a terminal presses Enter by accident, or out of habit, far more often than a script ever would. A prompt string typed and then immediately deleted, or a stray Enter between two real requests, should cost nothing — no wasted round trip to the model, no new entry in the session array at all. The rule is simple: after trimming whitespace, an empty result means "say nothing, ask again."
Filter two — the exit command. /exit is the one string this loop treats as a command rather than a message to the model. It's the harness's own private vocabulary, never seen by the model, never appended to the session array at all — the loop simply recognizes it and stops asking for more input.
Anything that survives both filters is real: append it as a plain user-role entry (a bare string content, exactly the shape lecture 01 §2's Snippet 2 showed for "what the REPL pushes for your typed input" — no content-block array needed here, since you're not the model producing mixed text/tool_use turns), and hand the whole session array, plus the object built in §1 that satisfies Asker, off to agentLoop.
Why It Matters
Notice what this loop does not do: it never inspects what the model said, never checks whether a tool was called, never touches anything below the surface of "call agentLoop once per real line of input." That's deliberate, and it's the entire reason lecture 05 built agentLoop to mutate its caller's array in place rather than return a new one (lecture 05 §4's "Why It Matters," if you want to reread the original argument): this loop can call agentLoop on turn one, then turn two, then turn three, each time handing over the same array — now longer than it was — and each call picks up exactly where the last one left off, with the model seeing every prior line, every prior tool call, every prior result, as if it had never stopped. If agentLoop had returned a fresh array instead, this loop would need an extra assignment (messages = await agentLoop(...)) at every single call site to avoid silently dropping history after the very first turn — a small thing to forget, and a session-destroying bug the moment you did.
Your Task
Spec. Implement the body of main()'s prompt loop. On each pass: display a prompt and obtain one line of real input through the Asker-shaped object described in §1; if the trimmed line is empty, skip straight to the next pass without touching the session array or calling anything else; if the trimmed line is exactly /exit, stop looping (no further prompt, no further model call); otherwise, append the trimmed line to the session array as an ordinary user turn, then drive one full round of agentLoop against that array and the same Asker-shaped object.
Worked example. A short, illustrative REPL session (yours will vary in the model's exact prose, never in this shape):
$ npx tsx index.ts
claude-code-nano — model: claude-haiku-4-5-20251001 — cwd: /Users/you/claude-code-nano (/exit to quit)
nano> list the files in this directory
● list_files({})
Here's what's in this directory: index.ts, package.json, tests/, fixtures/, solution/...
nano>
nano> /exit
$
Three lines typed, one real request. The blank Enter between the two real lines produced no tool call, no model call, no new entry in the session array — it simply asked again. /exit ended the loop without ever reaching the model.
Constraints (from tests/06_repl.test.ts): the test file cannot safely drive this loop end-to-end (no real terminal, no scripted stdin plumbed into a live process) — it checks two narrower things instead: main must be an exported function, and merely importing index.ts must produce none of main()'s observable effects (no banner, no client construction, nothing that would need ANTHROPIC_API_KEY). Both are things you get automatically once your main() body only ever runs from inside the guarded call at the bottom of the file — see §1.
Hint: You already know the two building blocks this needs. The object satisfyingAskerfrom §1 has exactly one method worth calling here — the same oneconfirmcalls in lecture 04, just with a different prompt string. And the check for "empty after trimming" is the identicalString.prototype.trim()you used insideconfirm's lenient-parsing logic back in lecture 04 §5 — same method, different purpose.
Common mistake: Checking for/exitbefore trimming the line. A human who types/exitwith a trailing space (an easy accident right before hitting Enter) would fail a strictline === "/exit"check iflinestill had that space attached. Trim first, then compare.
Unlock questions:
- The session array is declared exactly once, outside this loop, not freshly inside each pass. What specifically would go wrong — in terms of what the model would or wouldn't remember — if it were instead re-created empty at the top of every single pass through the loop?
- Suppose you swapped the order of the two filters — checked for
/exitfirst, then checked for blank input second. Would any observable behavior change from swapping that order, given the two conditions can never both be true for the same line? - The worked example's second
nano>prompt (the blank line) never appends anything to the session array. If you appended an empty-string user turn to the array anyway, before deciding to skip the model call, would that break./ok 06's two tests? Would it break anything else you've built in prior lectures?
Checkpoint
There's no isolated -t filter to run here the way earlier lectures let you check one case at a time — tests/06_repl.test.ts has exactly two tests, and neither exercises this loop directly (see §4 below for why, in full). For now, the honest checkpoint is: does this code compile, and does it match the behavior described in the spec and worked example above, by your own reading? Section 4 covers what ./ok 06 can and can't tell you; Section 5 is where you find out for real.
Stuck? See Snippet 2 in the Appendix.
3. Problem 6.1b: Crash Recovery — Repairing a Broken Invariant
Concept
Every scripted test in lecture 05 handed agentLoop a fake model function that always, eventually, answers — even test (c)'s missing-file scenario resolves cleanly, with a scripted second reply arriving right on schedule. A real network call to a real API has no such guarantee. It can time out. It can fail with a rate-limit error. It can drop the connection halfway through. And when it fails at the wrong moment — specifically, right after the harness has already pushed the model's assistant turn onto the session array, but before that turn's tool_use requests have been answered and a matching tool_result message has gone back — the session array is left holding something the API itself will refuse to accept ever again: an assistant turn with unanswered tool requests, and nothing behind it.
You predicted this exact failure mode once already, without any code to back it up: lecture 01 §3's unlock question 6 asked what would happen if a tool_use's answer were deleted before the next model call, and the checkpoint's answer was unambiguous — "the API would reject the request." That's not a hypothetical anymore. It's a real, recoverable failure a live session can hit, and main() is the only place in this entire course positioned to notice and repair it, because it's the only place that survives a failed agentLoop call and gets to decide what happens next.
A dangling assistant message with an unanswered tool_use makes the next API call invalid, so it has to be dropped. That's precisely the repair main() performs: not a general-purpose crash handler, but the restoration of one specific, well-understood invariant — every assistant turn's tool requests have matching results — that a real API call can leave broken in exactly one way, at exactly one point in the session array.
Why It Matters
Get the scope of this repair right, and it costs one line; get it wrong, and you'll either fail to save a genuinely recoverable session or, worse, silently discard something the user actually needs. The failure this section targets is narrow and specific — it is never a problem with the user's own line (that turn is always complete the instant it's appended; there's no way for typing a message to leave it "half-typed" in the array), and it is never a problem with a tool_result message (those only ever get appended after runTool, confirm, or the denial path has already produced a complete answer — there's no half-finished tool_result). The one and only entry that can be left dangling — present, but structurally invalid on its own — is an assistant turn whose tool_use blocks never got their answers, because the crash happened between "the assistant's reply was pushed" (lecture 05 §2, step 2) and "the results were pushed back" (lecture 05 §4). Removing any other kind of entry to "clean up" after an error would be actively wrong: it would erase a perfectly valid piece of the conversation's history over a problem that entry never had.
Your Task
Spec. Wrap the call to agentLoop in error-handling. If it throws, report the failure to the user (the error's message is the natural thing to show), and then inspect the very last entry currently in the session array. If — and only if — that entry belongs to the assistant, remove it from the array before returning to the top of the prompt loop. If the last entry belongs to the user (or the array is otherwise in a state where the last entry doesn't need repair), leave the array untouched.
Worked example. Suppose agentLoop is midway through a turn: it has just pushed an assistant reply containing a tool_use block for bash, and the network call for the next round (after running that tool and trying to send the result back) fails before the call ever resolves — say, the connection drops during the gate's confirm prompt, or the retry to the model itself fails. main() catches that failure, prints it, and finds the session array's last entry is that dangling assistant turn — no tool_result was ever appended for it, because the failure happened before that could occur. It removes that one entry. The array now ends on the user's original request again — a valid, API-acceptable history — and the very next line you type resumes the conversation cleanly, rather than every future call being rejected by the API for the reason lecture 01 §3 already predicted.
Constraints: nothing in tests/06_repl.test.ts exercises this path directly (see §4) — this is verified by your own reasoning against the spec above, and, for real, by the live run in §5, where a genuinely interrupted call (say, hitting Ctrl-C mid-request, or a transient network error) is the only way to actually witness it firing.
Hint: You need exactly one piece of information about the array's current last entry — which role it belongs to — and exactly one operation if that check passes: remove that one entry from the end. Both are single, small operations on the same array main() already built in §1; nothing here requires walking the array's earlier history or inspecting the dangling turn's content, only which role produced it.
Common mistake: Removing the last entry unconditionally, on any thrown error, without checking its role first. If the failure instead happens on a call where the user's turn was the last thing pushed (say, the very first model call of a brand-new line fails immediately, before any assistant reply exists yet), removing that entry would silently delete the request you just typed — the user's line was never the problem, and there was nothing dangling to repair.
Unlock questions:
- Why must the check be specifically "is the last entry an assistant turn," rather than "did an error just occur" alone? Walk through what a broken repair (unconditional removal) would do to a session where the very first call of a brand-new turn fails, with no assistant reply having been pushed at all yet.
- Lecture 05 §4 established that
agentLoopmutates itsmessagesargument in place rather than returning a new array. Why is that property — not just convenient, but load-bearing — for this repair to even be possible? What wouldmain()have to do differently ifagentLoopinstead returned a brand-new array on every call? - Suppose a thrown error left the session array's last two entries as
[..., assistant-turn-with-tool_use, user-turn-with-tool_result]— i.e., the assistant's request did get a matching answer pushed back, and the failure happened on the next round's model call instead. Should this repair remove anything in that case? Why or why not, given the invariant this section is protecting?
Checkpoint
Same honest answer as §2: there's no ./ok-checked test exercising this path in isolation. Read your implementation against the spec and the three unlock questions above; the real verification is watching this not need to fire during a clean live run (§5), and trusting it to fire correctly on the rare occasion a real network hiccup interrupts a call — which is, itself, hard to force on demand, and not something this lecture asks you to reproduce artificially.
Stuck? See Snippet 3 in the Appendix.
4. Running ./ok 06 — and Why Its Bar Is Different
Concept
Run it:
./ok 06
Real output, captured from this exact repo, before a single line of main()'s body has been written — the untouched skeleton, main() still a bare throw new Error("YOUR CODE HERE"):
ok: running tests for problem 06
RUN v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano
Test Files 1 passed (1)
Tests 2 passed (2)
Both tests green. Nothing written yet. Read that again, because it's not a typo, and it's genuinely different from every prior lecture's suite: tests/00_setup.test.ts failed on the stub warmup() until you fixed it; tests/02_read_only_tools.test.ts failed every case that actually called a runTool branch; tests/05_agent_loop.test.ts failed all five of its scripted conversations against a stub agentLoop. tests/06_repl.test.ts checks exactly two things, and both of them are already true about a file where main() does nothing but throw: main is exported as a function (a stub that throws when called is still, syntactically, a function — nothing calls it during this test), and importing index.ts has no side effects (true of the stub, and — per §1 — true of a correctly guarded real implementation too).
This isn't a gap in the test suite's care. It's the file's own top comment telling you exactly why, verbatim: "main() drives a real readline loop over stdin, so we don't puppet it here." Every previous lecture's tests could inject a scripted fake in place of the one slow or unpredictable dependency — scriptedAsker standing in for a human, scriptedModel standing in for the network. main() has no injectable seam of its own for its input source; it builds a real terminal-backed reader directly, on the spot, every time it runs. That's a deliberate design choice, not an oversight: every function below main() in this course — runTool, preview, confirm, callModel, agentLoop — was written to accept its slow, unpredictable, or side-effecting dependency as a parameter, specifically so tests could substitute something fast and scripted. main() is the one function in the whole file that isn't built that way, because it's the one function whose entire job is to wire up the real thing — the real terminal, the real conversation loop — for an actual human to use. Software engineers sometimes name this split explicitly: a functional core (the testable, injectable, side-effect-free-when-possible machinery — everything below main()) wrapped in a thin imperative shell (the one place that actually touches the real world end to end, with nothing further downstream to fake). You've been building the core for five lectures. Today you wrote the shell, and the shell is, by its nature, the one part of this course ./ok was never going to be able to fully verify for you.
Why It Matters
This matters because it changes what "green" means for this lecture specifically. For lectures 02 through 05, ./ok passing was the whole verification story — if the suite was green, your implementation satisfied every behavior the tests could check, and that was a meaningful, comprehensive bar. For lecture 06, ./ok 06 green is necessary (it confirms you haven't broken the two structural guarantees — main still exists as a function, and importing the file still has zero side effects) but it is not sufficient to confirm your main() actually works as a REPL. That gap isn't a flaw in this course's design; it's an honest admission that some behavior — "does a live, human-driven, network-backed conversation loop actually work end to end" — cannot be reduced to a fast, offline, scripted assertion without building an entire fake terminal and a fake Anthropic API server, which is far more machinery than a course this size should ask you to build just to test its own final function. Real verification for main() comes from reading your own code against §§1–3's specs, and — the actual point of this entire course — from §5 below.
Your Task
Confirm, for yourself: run ./ok 06 before writing anything and note that it's already green (you can literally run it against the untouched skeleton right now, exactly as shown above). Then implement §§2–3. Run ./ok 06 again — still green, unchanged, for the same two reasons. If your implementation somehow makes it turn red (say, you accidentally gave main a different exported name, or added a top-level side effect that runs at import time, outside the guard), that's real signal something is wrong — but a green ./ok 06, on its own, has never been proof your loop behaves correctly. Only §5 gives you that.
Checkpoint
./ok 06: 1 test file, 2 tests, both green — true before you write a line of main(), and true after, for a correctly guarded implementation. That's the entire machine-checked bar this lecture sets, and it's genuinely the whole reason the curriculum pairs it with the live acceptance run rather than trusting ./ok alone, the way every earlier lecture could.
Stuck? See Snippet 4 in the Appendix.
5. The Live Acceptance Run
Concept
This is the moment the whole course has been pointed at since lecture 01's very first sentence. Everything below is real: a real API key, a real network call, a real model deciding what to do, a real y/n gate you answer yourself, and a real file on your disk that either stays broken or gets fixed depending on what happens next. Nothing here is scripted. Nothing here is checked by ./ok. This is the payoff of actually running the thing you spent five lectures building — not a test, a demonstration.
What you need: a real ANTHROPIC_API_KEY, exported into your shell's environment (export ANTHROPIC_API_KEY=sk-ant-...). The skeleton's default model — MODEL = process.env.NANO_MODEL ?? "claude-haiku-4-5-20251001" — is already wired to fall back to a Haiku-class model, chosen deliberately because a fixture-bug-fixing conversation like this one runs to completion in a handful of turns and costs pennies, typically a small fraction of a cent, at Haiku-class pricing. If you'd rather point at a different model, set NANO_MODEL before running — the skeleton already reads that override; nothing about it needs to change.
If you don't have a key available right now: the course is still complete. ./ok 06 green (§4) plus the six lectures' worth of unlock questions is the graded 10/10. This section is the victory lap, not a gate — come back and run it the moment a key is available, and treat everything below as the payoff waiting for you when you do.
Why It Matters
Every other checkpoint in this course asked "does the scripted behavior match the spec." This one asks a different question, for the first time: does a real model, seeing the real tool descriptions you built, produce the behavior this whole course has been describing in the abstract since lecture 01? Lecture 05 §5 already told you precisely what this section proves and what it doesn't: a scripted test can prove the mechanism is wired correctly (an error's real text reaches the model, a denial doesn't crash anything, a tool_use_id gets matched to the right result) without proving a real model, faced with that same mechanism, does anything sensible with it. This is where that second, harder claim finally gets tested — not by an assertion, but by your own eyes.
Your Task
Step 1 — confirm the bug is live. Before touching your own agent, check the fixture with your own hands:
node fixtures/convert.js 100
Expected right now: 87.55555555555556 — the inverted formula from lecture 01 §3's trace, still sitting in fixtures/convert.js exactly as it's shipped throughout this entire course. (100°C should convert to 212°F; this file is currently wrong on purpose.)
Step 2 — start your agent. From the claude-code-nano/ directory:
npx tsx index.ts
You should see the startup banner your main() prints, then a nano> prompt waiting for you.
Step 3 — give it the exact task. Type this (or something close to it — the point is asking for all three: find, fix, and verify):
There's a bug in fixtures/convert.js — the temperature conversion looks wrong. Can you find it, fix it, and verify your fix by running the script with input 100?
Step 4 — watch. What follows below is illustrative — a plausible shape for what you'll see, not a transcript from an actual run of this lecture (no live API call was made in writing this course material, and real model output varies run to run, wording to wording). Expect, in rough order: a logged tool call reading fixtures/convert.js; a [permission] preview showing the old/new formula for an edit_file call, waiting on your y; after you approve, a second [permission] preview for a bash call running the script with 100 as input, waiting on a second y; after you approve that one too, the number 212 appearing somewhere in the tool's output; and a closing text summary from the model saying, in its own words, that the bug is fixed and verified.
Step 5 — verify it yourself, independently. Don't just trust the model's summary — check the disk the same way you checked it in Step 1:
node fixtures/convert.js 100
Expected now: 212.
Step 6 — restore the fixture for next time. This file needs to stay broken for the next person (or the next time you) run this exact exercise. Since claude-code-nano/ is a real git repository with this fixture tracked and unmodified until just now, put it back:
git checkout -- fixtures/convert.js
Step 7 — end the session. Type /exit at the prompt to close the REPL cleanly.
Checkpoint
Two independent confirmations, both from your own hands, not the model's word: node fixtures/convert.js 100 reads 212 immediately after your agent's fix (Step 5), and reads 87.55555555555556 again after Step 6's restore — meaning the fixture is back to its starting condition and git status inside claude-code-nano/ shows a clean working tree, exactly as it did before you started. If both hold, you have just watched your own code produce, for real, the transcript lecture 01 §3 asked you to predict by hand before a single line of this course's code existed. That's the whole course, closed.
No appendix snippet for this section — there's nothing to reference-check against. The transcript above is illustrative by design; your own run is the only ground truth.
6. What You Built: The Whole-Course Retrospective
Concept
Six lectures, one file, five sections. Before reading the mapping below, try reconstructing it yourself: for each of the five sections named in index.ts's own header comment (TOOLS, GATE, MODEL, LOOP, REPL), which lecture(s) built it, and — harder — name one thing real Claude Code has in that same area that this course's version deliberately doesn't.
Here's the mapping, section by section:
index.ts section | Built in | What Nano has | What real Claude Code adds |
|---|---|---|---|
1. TOOLS + runTool | Lectures 02–03 | 5 tools, one dispatch table, errors-throw convention | Dozens of tools (search, notebook editing, web fetch, sub-agent dispatch), MCP servers for arbitrary third-party tools, tool sets scoped per agent/session |
2. GATED + preview + confirm | Lecture 04 | A fresh y/n on every gated call, no memory between calls | Persistent, configurable permission rules (settings.json), distinct permission modes (default / accept-edits / plan / bypass), hooks that can intercept or auto-decide a tool call programmatically |
3. callModel | Lecture 05 §1 | One blocking call per turn, a lazily-built client, an injectable seam for tests | Streaming responses (partial output as it's generated, not one blocking wait), prompt caching, model routing across a whole family of models, retry/backoff on transient failures |
4. agentLoop | Lecture 05 §§2–4 | The exact core trick: call, push verbatim, execute, feed back, repeat until no tool calls | Context compaction/summarization when a conversation gets long, parallel execution of independent tool calls, a real subagent-dispatch tool (bonus lecture 07 builds exactly that), interrupt handling mid-turn |
5. main | Lecture 06 (today) | A plain readline prompt, one long-lived array, crash recovery for one specific broken invariant | A full terminal UI (not a bare prompt), slash commands beyond /exit, resumable sessions across process restarts, multiple concurrent conversations, IDE and background-task integration |
Why It Matters
Look at what's identical, not just what's missing. The core claim lecture 01 opened with — "an agent is a model that can ask, plus a harness that runs and reports back, looped" — is not a simplification of what real Claude Code does. It's a literal, accurate description of real Claude Code's architecture, at the same level of abstraction this course has used the whole time. Every item in the right-hand column above is engineering added around that same loop — more tools, richer permissions, faster model I/O, a nicer terminal — not a different architecture underneath it. You didn't build a toy model of a more sophisticated system. You built the actual core of that system, at a scale small enough to read start to finish in six lectures, and everything real Claude Code adds on top is legible now in a way it wasn't six lectures ago, because you know exactly which of the five sections above each addition would slot into.
Your Task
Pick one item from the "What real Claude Code adds" column above — any one — and, in a sentence or two, explain concretely which of the five functions you built this course (runTool, confirm, callModel, agentLoop, or main) would need to change to support it, and roughly how. (This is deliberately open-ended; there's no single correct answer, and it's meant to test whether the five-section map above is genuinely load-bearing in your understanding, not just a table you skimmed.)
Checkpoint
There's no answer key graded against yours here — the mapping table above is the checkpoint. If you can point at a real-Claude-Code feature and immediately say which of your five sections it would extend, rather than needing to re-read the whole file to figure that out, the architecture has actually landed. That's the real objective of this entire course, stated as plainly as it can be: not "you can now use Claude Code," but "you now know, precisely, what it's doing when you do."
Stuck? See Snippet 5 in the Appendix.
7. Extra Challenges (0 points — the training wheels come off)
Concept
Everything up to this point came with a spec precise enough to be checked by a machine, and an appendix precise enough to check yourself against if you got stuck. The first two below don't. No tests exist for them; none will be added; there is no reference solution in this course's appendix, on purpose. (The third proved too good to leave untested — it graduated into bonus lecture 07.) That's not an oversight — it's the deliberate design of an "extra challenge" section in a course built entirely around ./ok and unlock-question answer keys: at some point, the scaffolding has to come off, or you never find out whether you can work without it. These three are real, general extensions of the exact codebase you just finished, each one a small, self-contained step toward something real Claude Code actually does.
Why It Matters
Each of the three below targets a different one of the five sections from §6's retrospective, deliberately — not because that's a rule, but because it's a natural way to see that "extending an agent" isn't one skill, it's several: extending the model's instructions (challenge 1), extending your own visibility into what the harness is doing (challenge 2), and extending the loop itself to call itself (challenge 3). None of them touches runTool's five existing cases, preview, or confirm at all — proof, again, of lecture 03's dispatch-table framing: real extension is additive, not a rewrite of what already works.
Your Task
Challenge 1 — NANO.md, a project-level system prompt. Real Claude Code loads a project's own CLAUDE.md file and folds its contents into the model's standing instructions, so a project can hand the agent context specific to itself — conventions, warnings, things not to touch — without that context living inside the model's training or being retyped every session. Build the same idea for Nano: at startup, check whether a file named NANO.md exists in the current directory, and if it does, fold its contents into what the model is told on every turn of that session. What you'll learn: this "feature" that sounds like a meaningful capability is, underneath, just string concatenation into one field you already built in lecture 05 — there's no new mechanism here, only a new source of text. The seam to modify: SYSTEM_PROMPT is currently a fixed constant, computed once, at module load; giving it a project-specific addition means it can no longer be a value fixed forever at that scope — think about when it's safe to check for the file's existence, given everything lecture 05 §1 told you about what should and shouldn't happen just from importing this module.
Challenge 2 — /context, a token-accounting REPL command. Real Claude Code can show you where a session's context budget is actually going — how much of it is system instructions, how much is tool schemas, how much is accumulated conversation. Build a second slash command, alongside /exit, that prints an estimated token count for each of: the system prompt, the TOOLS schema array, and the conversation history accumulated so far in the session. Since this course never imports a real tokenizer, a rough character-count-based estimate (a common rule of thumb: roughly four characters per token, for English text) is entirely sufficient — the point isn't precision, it's visibility, and this is exactly the kind of accounting that would eventually motivate the "context compaction" feature this course's curriculum names, explicitly, as out of scope. What you'll learn: tool schemas are often surprisingly large relative to a short conversation, and a long-running session's history — not its instructions — is usually what eventually dominates a real budget. The seam to modify: main()'s prompt loop already has one place where it recognizes a special string instead of treating a line as a message to the model (/exit) — a second special string slots in right alongside it, with no changes needed anywhere else in the file.
Challenge 3 — graduated to bonus lecture 07. The third challenge originally sketched here — a task tool that runs an entire nested agent and reports one final answer back to the parent — turned out to deserve better than a sketch: it is now lecture 07, with a given task schema in TOOLS, a real ./ok 07 suite, and a +1 bonus point on the course scale. One design note if you already started from this section's original wording: the tested design does not make runTool async — runTool stays a synchronous, model-blind dispatch table, and the delegation branch lives inside agentLoop, where the model and the gate already flow. Lecture 07 explains why that seam, and not runTool, is the right one. Go earn the eleventh point.
Checkpoint
There isn't one, and that absence is the point. No ./ok target checks challenges 1 and 2; no appendix snippet exists to compare your attempt against. (Challenge 3 now has both — see lecture 07.) "Done" means you can start npx tsx index.ts yourself and demonstrate the behavior you built — a NANO.md file's contents visibly shaping the model's behavior, a /context command printing numbers that plausibly account for where your tokens are going, or a task tool call that visibly runs a whole separate mini-conversation before handing back one line to the parent. Bragging rights only, exactly as the curriculum's points table says — and, unusually for this course, entirely self-graded with no ground truth to check against but your own judgment of whether it does what you meant it to do.
Key Takeaways
- The entire session's memory is one array, created exactly once, mutated by every turn.
main()doesn't introduce a new idea here — it's the first place in the course where the array lecture 01 §2 described finally has a real beginning and a real, human-driven ending (/exit), rather than a scripted array a test constructed for you.
- TypeScript's structural typing is why the real terminal-backed reader satisfies
Askerwith zero adapter code. A value matches an interface by shape — the right method names and signatures — not by declaring, by name, that it implements anything. This is the payoff of lecture 04'sAskerdesign: the same interface accepted ascriptedAskerin a test and a real interactive reader in production, withconfirm(andagentLoop) never needing to know or care which one it got.
- The main-module guard is the reason every test file in this course can safely
import { ... } from "../index.ts"without triggering a live REPL. It's a single condition comparing "the file that was actually executed" against "this module's own identity" — given code, never yours to modify, and the entire reason importing this file has stayed side-effect-free across six lectures of tests.
- Crash recovery here targets one specific, narrow invariant — not a general error handler. Only a trailing assistant turn with unanswered tool requests is ever dangling; user turns and tool-result turns are never the thing to repair, because they're never left half-finished by a failed network call the way an assistant turn's tool requests can be.
./ok 06checks less than every prior lecture's suite, on purpose, and that's not a flaw.main()is this course's one genuine "imperative shell" — the single function with no injectable seam standing between it and the real terminal — wrapped around five lectures' worth of a fully testable "functional core." Green./ok 06confirms two structural guarantees; it was never going to be able to confirm the REPL itself works, and the course doesn't pretend otherwise.
- The live acceptance run is the actual point of the whole course, not a bonus lap. Lecture 01 asked you to predict a transcript with no code behind it. Six lectures later, your own ~350 lines produce it for real, against a real model, with your own "y" approving the fix.
- Every one of the five sections in
index.tsmaps to something in real Claude Code that's the same trick, scaled up — more tools, richer permissions, faster model I/O, a nested version of the loop itself, a real terminal UI — never a different architecture underneath. You built the actual core, not an approximation of it.
What we skipped (for future study, beyond this course)
- Streaming responses.
callModelandagentLoop, from lecture 05 through today, always wait for one complete reply before acting on any of it. Real Claude Code streams partial output as the model generates it — a meaningfully different shape (partial content blocks arriving incrementally, before a turn is even finished) that this course never touches. - Context compaction / summarization. This course's conversations never run long enough to need it, by design. Real long-running sessions eventually have to summarize or discard older turns to stay within a model's context window — challenge 2 above (
/context) is this course's one nod toward the accounting question that motivates compaction, without building compaction itself. - MCP, hooks, skills, multi-model routing. Real Claude Code can pull in tools from arbitrary third-party servers (MCP), intercept or auto-decide tool calls with custom scripts (hooks), package reusable instructions (skills), and route different requests to different models entirely. None of that exists here;
TOOLSis a fixed array of five,callModeltalks to exactly one hardcoded model family. - Robust sandboxing and security. The y/n gate in front of
write_file,edit_file, andbashhas been this entire course's one and only safety mechanism, by design, since lecture 04 §7 drew that line explicitly. Nothing in this course restricts what a human can approve, sandboxes the shellbashgets, or stops a path traversal outside the project directory. - A real terminal UI.
main()'s prompt is a barereadlineloop — one line in, one line out, no split panes, no live-updating tool-call indicators, no rendering of markdown or diffs beyond plainconsole.log. Real Claude Code's interface is a much richer terminal application built on top of the same underlying loop this course'sagentLoopimplements. - Resumable and concurrent sessions. This course's one session array lives and dies with one run of
npx tsx index.ts; closing the process discards it entirely. Real Claude Code can resume a past session and run multiple conversations concurrently — neither of which this course's single in-memory array supports.
Appendix: Reference Implementation
Check your work here after attempting each section. Don't read ahead — one snippet at a time. This is the finale — every snippet below is drawn character-for-character from solution/index.ts.
Snippet 1: The module guard (unchanged)
// Start the REPL only when run directly — importing this file for smoke
// checks or tests must stay side-effect free.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
This line has been sitting at the bottom of index.ts since lecture 01 — nothing about it changes this lecture, or ever, in this course. pathToFileURL (imported at the top of index.ts from node:url) converts process.argv[1] — the path Node was told to execute — into the same URL format import.meta.url already uses to identify the current module, so the two are directly comparable. main() only runs when they match: when index.ts was the thing actually launched (npx tsx index.ts), not merely import-ed by something else.
Snippet 1: The module guard (unchanged) — unlock answers
main()prints its startup banner as its very first observable action, before the prompt loop even begins. Sincemain()is the only code path that would ever print that banner, its absence after animport(with no explicit call tomain()anywhere in the test) is direct, checkable evidence thatmain()never ran — which is exactly what the guard promises. The test can't safely try to drive a real REPL, so it settles for confirming the one visible side effect that would prove the REPL started, didn't.- Without the guard,
main();would run unconditionally the momentindex.tsis loaded as a module — which happens at the very top oftests/00_setup.test.ts(import { warmup } from "../index.ts"), a test file that has nothing to do with the REPL at all.main()would immediately try to build a real readline interface against the test runner's own standard input — not an interactive terminal, and never fed any lines — and then wait, forever, for a line that will never arrive. Every test file's import statement, in every lecture, would hang before a singleit(...)block even started../ok(bare, or for any single lecture) would never produce output again.
Snippet 2: The prompt loop
export async function main() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const messages: Anthropic.MessageParam[] = []; // the ENTIRE conversation state — nothing else persists
console.log(`claude-code-nano — model: ${MODEL} — cwd: ${process.cwd()} (/exit to quit)`);
while (true) {
const line = (await rl.question("\nnano> ")).trim();
if (!line) continue;
if (line === "/exit") break;
messages.push({ role: "user", content: line });
try {
// The readline interface IS an Asker — it has a matching question().
await agentLoop(messages, rl);
} catch (err: any) {
// Snippet 3 goes here — see below.
}
}
rl.close();
}
Snippet 2: The prompt loop — unlock answers
- If
messageswere re-created empty inside every pass of the loop, the model would lose every prior turn the instant you pressed Enter on your next line — each call toagentLoopwould see only that one new line, with no memory of anything said before it. You'd effectively get a brand-new, context-free conversation on every single line typed, which defeats the entire point of a persistent session; the model can only "remember" what's still sitting inmessageson the call it's currently answering (lecture 01 §2). - No observable difference — the two conditions are mutually exclusive (a trimmed line is either empty, or it's
"/exit", or it's something else; it can never be both empty and equal to"/exit"at once), so checking them in either order produces the identical control flow for every possible input. - It wouldn't break
./ok 06's two tests — neither test drives this loop at all, so nothing about this loop's internal behavior is checked by them. It would, however, be a real defect against the spec: an empty-string user turn sitting inmessageswould then get sent to the model on the next real call, as a genuine (if content-free) turn in the conversation history — the model would see a blank message it never should have been shown, and the whole point of the blank-line filter (skip silently, cost nothing) would be undermined.
Snippet 3: Crash recovery
} catch (err: any) {
console.error(`[error] ${err?.message ?? err}`);
// If the API call failed right after an assistant message containing
// tool_use, the history is invalid (a request with no result) and every
// future call would be rejected — drop that dangling message.
if (messages.at(-1)?.role === "assistant") messages.pop();
}
Snippet 3: Crash recovery — unlock answers
- If a failure on the very first model call of a brand-new user line were repaired by unconditionally removing the array's last entry, that last entry would be the user's own line you just typed (no assistant reply exists yet to have been pushed) — an unconditional repair would silently delete the request that failed, with nothing actually dangling to fix. The role check is what tells the difference between "an assistant turn's tool requests never got answered" (needs repair) and "the user's own turn simply never got a reply at all yet" (nothing to repair — that turn is complete and valid on its own; it just hasn't been answered).
- Because
agentLoopmutates the very arraymain()is holding a reference to,main()'s repair (messages.pop()ing that same array) directly fixes the exact object the next iteration of the prompt loop will hand toagentLoopagain — no extra bookkeeping needed. IfagentLoopinstead returned a brand-new array on each call,main()would need to track which array was "current" at all times, decide whether a partially-updated returned array (if one even existed after a thrown error) was the one to repair, and reassign its own local variable accordingly — a meaningfully more complex, more error-prone way to keep the persisted session and the "last known good" array in sync. - No repair is needed, and none should happen.
[assistant-turn-with-tool_use, user-turn-with-tool_result]is a complete, API-valid pair — everytool_usein that assistant turn already has its matchingtool_resultsitting right behind it. The failure in this scenario happened on the next round's call to the model, with the array's last entry being that completetool_resultturn (role"user"), not a dangling assistant turn — the role check in the snippet above (role === "assistant") correctly leaves this array untouched, because there's nothing broken to fix.
Snippet 4: ./ok 06, before and after
Before any of main()'s body is written (the untouched skeleton):
ok: running tests for problem 06
RUN v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano
Test Files 1 passed (1)
Tests 2 passed (2)
After §§2–3 are fully implemented, run against a temporary copy of this exact repo with solution/index.ts's main() in place of the stub:
ok: running tests for problem 06
RUN v4.1.10 /path/to/temp/copy/claude-code-nano
Test Files 1 passed (1)
Tests 2 passed (2)
Identical output, both times — confirmed directly against this repo. That's not a mistake in either run; it's §4's entire point. Running ./ok with no argument at all against that same temporary copy (all six lectures' solutions in place) confirms nothing regressed elsewhere either:
ok: running tests for ALL problems
RUN v4.1.10 /path/to/temp/copy/claude-code-nano
Test Files 6 passed (6)
Tests 31 passed (31)
31 tests, 6 files, all green — the entire course, done. (The real claude-code-nano/ repository itself is untouched by this check — git status there shows a clean working tree throughout, since all of this verification runs against a disposable copy.)
Snippet 5: Retrospective notes
There's no single "correct" answer to §6's task — it's a comprehension check, not a graded problem. A representative example, in case you want something to compare your own reasoning against: real Claude Code's parallel tool execution (independent reads running concurrently instead of one after another) would primarily change agentLoop — specifically, the for...of loop walking response.content and calling runTool for each tool_use block one at a time would need to become something that starts every independent tool call at once and waits for all of them together, rather than awaiting each one before starting the next. Nothing about runTool, preview, or confirm themselves would need to change at all — the change is entirely in how the loop orchestrates calls it already knows how to make individually, which is itself a small preview of the kind of reasoning lecture 07's task tool asks you to do for real.
End of lecture. End of course. Ten points, six lectures, one file — and, if you ran §5, one real conversation with a real model that found a real bug in a real file and fixed it, because you built every single piece that made that possible.