The Agent Loop
Lecture Overview
Four lectures of setup end here. You have five working tools (runTool), a permission gate that pauses for a human y/n (preview + confirm), and — from lecture 01 — a complete, hand-traced model of what a Claude Code conversation looks like as data: a flat messages array, tool_use blocks the model emits, tool_result blocks the harness answers with. What's missing is anything that runs: TOOLS describes tools, runTool executes them, confirm gates them — but nothing has called any of them in sequence, because nothing has talked to a model. Today you write the two functions that make it move: callModel, the one function that reaches the Anthropic API, and agentLoop, the while loop that calls the model, runs whatever it asks for, and keeps going until it stops asking.
agentLoop is short — about 30 lines — but every line calls something from a previous lecture: runTool (02–03), GATED and confirm (04), and the messages protocol you traced by hand in lecture 01 §3. If any of those left a gap, this is where it surfaces, since agentLoop is the first code in this course that exercises the full chain end to end. It's also where the course's central claim gets proven rather than asserted: lecture 01 told you an agent is a model that can ask plus a harness that runs and reports back, looped. Today you write the loop, run it against a scripted fake model, and watch your own code generate — mechanically, deterministically — the exact six-entry transcript you predicted by hand in lecture 01 §3: the transcript you traced there, now produced by your code.
How this lecture works: two numbered problems — 5.1 (callModel, one API call) and 5.2 (agentLoop, three lettered stages: the shape of one iteration, executing tool calls, the termination condition) — all checked together by ./ok 05 against tests/05_agent_loop.test.ts. Each stage gets its own spec, worked example, constraints, and unlock questions answered in the Appendix. This lecture is worth 2.5 of the course's 10 points — the largest of any lecture.
1. Problem 5.1: callModel — the One Function That Talks to Anthropic
Concept
Everything you've built so far — runTool, preview, confirm — runs entirely on your machine: files, shell, a human typing y/n. callModel is different in kind: it's the single function in this entire codebase that leaves your machine and talks to a remote model. Open index.ts and look at its neighbors — MODEL, SYSTEM_PROMPT, and a let client: Anthropic | undefined; declared just above it. All three exist only to support callModel; nothing else in the file touches them.
callModel makes exactly one API call, and that call needs five things to mean anything. Which model to talk to — the MODEL constant, a specific model id, not a family name. A token cap — max_tokens, a budget the reply isn't allowed to exceed, not a target it aims for. The system prompt — SYSTEM_PROMPT, the standing instructions that apply to every turn of the conversation, not just this one. The tools contract — TOOLS, the schema array lecture 02 spent an entire section teaching you to read as a contract; this is the one place in the whole project where that contract actually leaves your machine and reaches the model, over the wire, every single call — nothing about TOOLS matters to the model until this line ships it. And the history — the messages parameter you were handed, so the model sees the whole conversation, not just the latest line. Drop any one of these five and you get a worse agent in a specific way: no model to answer, no cap on runaway generation, no standing instructions, no tools it's allowed to ask for, or no memory of anything said so far.
The other thing to understand before writing this function is when the Anthropic client itself gets built. It can't happen once, at the top of the file, the moment index.ts loads — it has to happen lazily, the first time callModel actually runs. Constructing a real Anthropic client reads ANTHROPIC_API_KEY from your environment, and every test file in this course — including every test in this lecture's own suite — imports index.ts at the top, often with no API key set at all. If client construction happened at import time, simply importing the file would risk failing before a single test assertion runs, for a reason that has nothing to do with what that test is checking. Building lazily — check whether a client already exists, construct one only if it doesn't, and reuse it on every later call — defers that risk to the one code path that actually needs a key: a real call to callModel.
Why It Matters
The comment above let client says the quiet part out loud: "created lazily so importing this file never needs an API key." If new Anthropic() ran at module load time — say, as const client = new Anthropic(); at the top level — then simply import-ing index.ts (which is exactly what every test file in this course does) would try to construct a real client immediately, and depending on the SDK's behavior, that could fail or behave unexpectedly in an environment with no ANTHROPIC_API_KEY set at all — which describes every single test run in this course, including the ones you're about to write for agentLoop. Laziness — construct on first use, not on import — is what keeps ./ok 05 running "offline and free" (the ok script's own comment, lecture 01 §5) even though callModel genuinely does real network I/O when actually invoked.
Now look at agentLoop's signature in the skeleton, specifically its third parameter: model: (messages) => Promise<Anthropic.Message> = callModel. That's a default parameter — a TypeScript/JavaScript feature you haven't needed until now: if the caller doesn't supply a model argument, agentLoop uses callModel automatically. callModel is the real backend; the default value is a convenience, not the design. The design is that model can be anything shaped like callModel — and tests/05_agent_loop.test.ts never once calls the real callModel. Every single test builds a scriptedModel(responses) — a fake function returning pre-baked Anthropic.Message-shaped objects one at a time from a list — and passes that as model instead. This is the exact same move lecture 04 taught you with Asker: confirm never called readline.createInterface directly, it depended on the narrowest interface it needed (question(prompt): Promise<string>) so tests could inject a scriptedAsker. callModel's injectability through agentLoop's model parameter is that identical seam, applied to the model instead of the human. You are about to write and fully test this course's largest function without ever making a real API call — and that's not a limitation of the test suite, it's the design lesson this lecture is built to teach.
Your Task
Spec. Implement callModel(messages: Anthropic.MessageParam[]): Promise<Anthropic.Message>. Lazily create client if it doesn't exist yet. Call the client's messages.create method, passing the model name, a max_tokens budget, the system prompt, the tools array, and the messages parameter you were given. Return the result.
Worked example. There isn't one you can run offline — that's the point of this problem, and §1's "Why It Matters" above already explained why. The "worked example" for callModel is really: does it compile, and does it match the shape agentLoop's default parameter expects (a function from Anthropic.MessageParam[] to Promise<Anthropic.Message>)? You'll get a real, live worked example in lecture 06's acceptance run, once ANTHROPIC_API_KEY is set and main() exists to drive it.
Constraints. None from tests/05_agent_loop.test.ts — grep it yourself if you don't believe that; the string callModel appears exactly once, in a comment. No test in this entire course calls the real callModel. Correctness here is checked by you, by reading, and — for real, eventually — by lecture 06's live run against the actual API.
Hint: The API call is one method call onclient, taking a single object argument with five named fields — the five things described above, each already sitting in this file under an existing name (MODEL, a number you pick,SYSTEM_PROMPT,TOOLS,messages); nothing needs to be computed or transformed.max_tokensis a plain number you choose (the solution uses a generous budget; there's no "correct" value the tests check, since no test calls this function). For the lazy-construction check, reach for the assignment form of the??operator from lecture 02 §4 (list_files'sinput.path ?? "."): tested againstclientandundefined, it assigns a fresh client only the first time, and leavesclientalone on every call after that.
Common mistake: Constructing new Anthropic() unconditionally, every call, instead of lazily once. This wouldn't fail any test (again — nothing tests this function directly), but it defeats the entire "lazy, cached client" design the comment describes, and would mean issuing a fresh SDK client object per turn of a real conversation for no reason.
Unlock questions:
- If
callModelconstructednew Anthropic()at the top of the file (module scope), instead of lazily inside the function body, what specifically would break about running./okin this course — walk through what happens the instant any test file'simport { agentLoop } from "../index.ts"line executes. agentLoop's parameter is writtenmodel: (...) => Promise<Anthropic.Message> = callModel, notmodel: (...) => Promise<Anthropic.Message>withcallModelchosen some other way (e.g. hardcoded inside the function body). What's the practical difference in testability between "default parameter, overridable by the caller" and "hardcoded inside, not overridable"?- Lecture 02 spent an entire section (§2) establishing that
TOOLS'sdescriptionfields are read only by the model, never by your TypeScript. Where, concretely, doesTOOLSactually get sent to the model — which line, in which function? (If your answer is "nowhere yet" from any lecture before this one, that's the right observation — name the line that changes that.)
Checkpoint
There's no ./ok command that isolates just callModel — as established above, nothing calls it directly. Move on to Problem 5.2; once agentLoop is also implemented, ./ok 05's five tests exercise the loop, not callModel (they all inject a scripted model instead). You'll confirm callModel itself is right by inspection now, and for real in lecture 06.
Stuck? See Snippet 1 in the Appendix.
2. Problem 5.2a: One Trip Around the Loop
Concept
Before writing agentLoop in full, get the shape of a single iteration fixed in your head — you'll wrap it in while (true) in §4, but the interesting design decisions all live inside one trip around the loop, not in the looping itself.
Recall from the skeleton's comment block above agentLoop: messages is an append-only array (lecture 01 §2 — never edited in place, only pushed to). Each iteration may discover more work — new tool_use requests — that gets handled on the next iteration once it's appended to messages. One trip around the loop does exactly three things, in order:
- Call the model. Await the injected
modelparameter, passing it the currentmessages— notcallModeldirectly by name, which is exactly what makes the whole function testable (§1). What comes back is anAnthropic.Messagewhosecontentfield is the array of blocks (text, tool_use, or a mix of both) the model produced this turn. - Push the assistant's reply, verbatim. Take that
contentarray exactly as the API returned it — not reformatted, not filtered, not summarized — and append it tomessagesas one new entry playing the assistant role. Whatever mix of blocks the model produced goes in unmodified. - Look at what it asked for. Walk the content blocks and find any
tool_useblocks — that's next section's job.
The word verbatim in step 2 is doing real work, and it connects directly to something lecture 01 §2 already told you: the model has no memory across calls except what's in messages. If you rebuilt response.content yourself instead of pushing it as-is — say, by only keeping the text blocks and dropping the tool_use ones — the next call to model(messages) would show the model a version of its own last turn that never actually happened. It asked for read_file; your rewritten history says it didn't. The model would have no way to reconcile that, because from its perspective, everything it "remembers" is just what's sitting in messages — there's no other memory to fall back on.
Why It Matters
The model's response.content can hold text and one or more tool_use blocks together — lecture 01 §3's unlock question 5 already established that a turn is never restricted to one kind of block, since content is typed as a plain array. Pushing that content onto messages before you've even looked at what's in it means the model's turn is recorded before you decide how to respond to it: you don't get to silently veto or edit what the model produced by rewriting history; you only control what your own next response looks like.
Your Task
Spec. Implement the first two moves of one loop iteration inside agentLoop, in index.ts: call the injected model function with the current messages, then append one new entry to messages, playing the assistant role, whose content is exactly the response's content array — unmodified. This code lives inside the while (true) you'll add in §4; for now, focus only on getting these two moves right. The tool-execution walk (§3) and the loop's continue-or-stop decision (§4) come next, and ./ok 05 won't go green until all three stages are in place together.
Worked example. Before: messages holds one entry, { role: "user", content: "hi" }. The scripted model's response has a content array with two blocks: a text block reading "checking now", and a tool_use block requesting read_file on "a.txt". After this stage runs once: messages holds two entries — the original user entry, unchanged, plus one new assistant entry whose content is that same two-block array, verbatim. (This is exactly the scenario the prediction question below asks you to work out in full detail.)
Predict, don't write code yet: suppose response.content is [{ type: "text", text: "checking now" }, { type: "tool_use", id: "tu_1", name: "read_file", input: { path: "a.txt" } }] — a text block and a tool_use block in the same turn, the same combination lecture 01 §2 first showed you. After step 2 above runs, what is messages.at(-1), exactly — not paraphrased, the literal object?
Then implement it: write these first two moves inside agentLoop's loop body. ./ok 05 stays red until §§3–4 are also written in — expected, since nothing yet executes a tool_use or decides whether to loop again.
Checkpoint
messages.at(-1) is { role: "assistant", content: [{ type: "text", text: "checking now" }, { type: "tool_use", id: "tu_1", name: "read_file", input: { path: "a.txt" } }] } — the entire response.content array, both blocks, pushed as one message's content, unmodified. If your prediction split this into two separate messages entries (one for the text, one for the tool_use), re-read lecture 01 §2: a single assistant turn is one array of content blocks, not one array entry per block. tests/05_agent_loop.test.ts's very first test ("stops after ONE model call...") checks messages has length exactly 2 after a text-only reply — one user entry, one assistant entry — which only holds if you push response.content as a single message, not one message per block.
Stuck? See Snippet 2 in the Appendix.
3. Problem 5.2b: Executing Tool Calls — the Gate, the Try/Catch, the Results Array
Concept
Now the interesting part: for each tool_use block in the assistant's reply, you must produce exactly one tool_result — and which of three paths that result takes depends on what you already built in lectures 03 and 04. This section is where GATED, confirm, and runTool's throw-on-failure convention all get called from the same dozen lines, for the first time.
Walk the content blocks one at a time, in the order the model produced them, and handle each by its type:
- A
textblock: print it —console.logis fine, the model's prose is meant to be read — and move on to the next block. Nothing else happens for a text block. - A
tool_useblock: first log a short one-liner naming the tool and its input, so a human watching the REPL can see what's about to happen. Then decide which of three paths this particular call takes, in order:- Gated, and denied. Check whether the tool's name is a member of
GATED(the exact set from lecture 04, unchanged). If it is, ask for approval the same way lecture 04'sconfirmdoes, and wait for the answer. If the answer is a denial, do not callrunToolat all — the whole point of the gate is that a denial means the action never happens. Instead, this block's result gets a human-readablecontentstring containing the word "declined" (checked case-insensitively —tests/05_agent_loop.test.tschecks.toLowerCase().toContain("declined")) and anis_errorflag offalse. Recall lecture 04 §6's forward-reference: "a denial is a normal result, not an error" — you predicted this exact shape three lectures ago, and now you're building it for real. - Gated and approved, or not gated at all (
read_file/list_filesskip the gate question entirely): run the tool. Wrap that call in atry/catch— recall lecture 02 §5's convention, restated as a promise back then: "agentLoopwill wrap everyrunToolcall in atry/catch, and when it catches something... it converts the exception into an ordinarytool_resultblock withis_error: true." Today is that promise's due date.- It succeeds: this block's result gets whatever
runToolreturned ascontent, andis_errorstaysfalse. - It throws: this block's result gets the caught error's message as
content(or the caught value itself, stringified, if it isn't a properError), andis_errorbecomestrue.
- It succeeds: this block's result gets whatever
- Whichever of these three paths a given
tool_usetook, the outcome is one result object whosetool_use_idis the id that matches this result back to the request it answers — copied straight from the block you just processed, the exact field lecture 01 §2 called "not cosmetic." Push each one onto a local array (call itresults) as you go — you're accumulating one entry pertool_useblock encountered this turn, not sending anything back tomessagesyet.
- Gated, and denied. Check whether the tool's name is a member of
Gloss — iterating content blocks directly. You want a loop that visits each element of the content array itself, not its indices — the same idea as list_files's .map() from lecture 02, just written as a loop keyword instead of a method call. This is the first time in the course you're writing that direct-iteration loop keyword yourself rather than reaching for an array method; look it up if the syntax for "loop over each element of an array, by value" isn't already familiar.
Why It Matters
Look at what this section does not do: it never checks "is this the kind of tool that usually throws" or special-cases bash versus read_file. Every gated tool goes through confirm the same way; every tool call goes through the same try/catch; the branch logic is uniform across all five tools, because the conventions — GATED membership, throw-on-failure — were established once, in earlier lectures, precisely so that agentLoop wouldn't need tool-specific logic here. This is the payoff of lecture 02's dispatch-table framing and lecture 04's gate: build the right primitives once, and the code that composes them stays almost embarrassingly simple. If you find yourself writing an if (block.name === "bash") anywhere in this section, stop — that's a sign you've reinvented something GATED or runTool's dispatch already solved.
The is_error/content distinction is also where you'll see the mechanism behind something that looks, from a distance, like self-correction. tests/05_agent_loop.test.ts's test (c) scripts a model that asks for read_file on a path that doesn't exist, gets back is_error: true with the real ENOENT message as content, and is scripted to reply "sorry, that file is missing" on its very next turn. Read that carefully: the reply is hardcoded into the test's script, not generated in reaction to the error — so what test (c) actually proves is narrower than "the model self-corrected." It proves the mechanism is wired correctly: the real error text genuinely reaches messages, legible and unfiltered, and the model gets a second turn to do something with it, exactly the way lecture 02 §5 promised. Whether a real model, handed that same error text, would actually produce something sensible is a claim this test can't make on its own — that's lecture 06's live run against the real API, where the reply isn't written in advance.
Hint: Short-circuit evaluation (&&,||) lets you fold "is this tool even gated?" and "did the human approve it?" into a single boolean condition, evaluated left-to-right — the same way you'd guard an array access witharr && arr.lengthto avoid touching.lengthon something that might not exist. Only tools where the first check passes ever reach theawait confirm(...)call at all.
Common mistake: Callingconfirmfor every tool, then checkingGATED.has(...)only to decide whether to look at its answer. This means an ungated call likeread_filewould sit thereawaiting a human's y/n for no reason — annoying in a real REPL, and a guaranteed hang againstnoAskerin tests (b), (c), and (e), whoseAskeris deliberately built tothrow new Error("gate should not have been consulted")the instantquestionis called. Those three tests exist specifically to catch aconfirmcall on a tool that was never supposed to trigger the gate.
Your Task
Spec. Implement the tool-execution walk inside agentLoop, in index.ts: for each block in the assistant reply you just pushed in §2, print text blocks and skip anything that isn't tool_use; for each tool_use block, produce exactly one result by following the gated-and-denied / approved-or-ungated-and-succeeded / approved-or-ungated-and-threw decision tree described above, and accumulate those results into a local array. This code sits between the assistant-push you wrote in §2 and the return-or-continue decision you'll write in §4.
Worked example. Suppose the reply pushed in §2 contains one block: a tool_use for bash with { command: "echo hi" }, and suppose (for this example only) bash were ungated. Running this stage's logic produces a results array with one entry: content containing the command's stdout and its exit code, is_error false, and an id matching the tool_use block's id. Now suppose instead the human answers "n" when asked to approve a real (gated) bash call — the results array still has exactly one entry, but its content contains the word "declined," is_error is still false, and — critically — the command never ran. Either way, one tool_use block in produces exactly one entry in results; nothing yet has touched messages itself — that's §4's job.
Two questions, answered from the test file directly (tests/05_agent_loop.test.ts, tests (c) and (d) — go reread them now if you haven't already):
- Test (d) passes
scriptedAsker(["n"])and abashcall whose command would create a sentinel file. The test assertsfs.existsSync(sentinel)isfalseafteragentLoopfinishes. What does that prove about the order of your two checks — doesconfirmhave to be called beforerunTool, or could you callrunToolfirst and just discard its result ifconfirmsays no? - Test (c) scripts
read_fileon a missing path and assertstr.is_erroristrueandmessages.at(-1)!.roleis"assistant"(i.e., the loop kept going for a second call). If youragentLoopimplementation let a thrownrunToolexception propagate all the way out ofagentLoopitself — notry/catchat all — what would happen to this test, concretely: would it fail with an assertion mismatch, or would it fail some other way entirely?
Then implement it: write this stage of agentLoop. ./ok 05 stays red until §4 is also written in — expected, since nothing yet decides whether to loop again or return.
Checkpoint
confirmmust be called beforerunTool, and if it returnsfalse,runToolmust never be called at all — not "called and its result discarded." CallingrunTool("bash", { command: "echo pwned > sentinel" })and then throwing away the return value would still create the file; the sentinel-file check in test (d) exists specifically to prove the command's side effect never happened, not just that its result was hidden from the model. "Ask first, act only on approval" is the only order that satisfies this.- The whole test would fail differently and worse: an uncaught exception thrown from deep inside
agentLoopwould propagate out of theawait agentLoop(...)call in the test itself, and the test would fail with that rawENOENTerror as an uncaught rejection/exception, never even reaching theexpect(...)assertions ontr.is_errorormessages.at(-1). The test never gets to check what it wants to check, because the function crashed before returning at all. This is the concrete cost of skipping thetry/catchlecture 02 §5 told youagentLoopwould need.
Stuck? See Snippet 3 in the Appendix.
4. Problem 5.2c: Feeding Results Back and Knowing When to Stop
Concept
You now have a results array — zero or more tool_result objects, one per tool_use block the model's last turn contained. Two things remain, and they're really one decision with two branches:
If results is empty — the model's last turn had no tool_use blocks at all, meaning it just talked (recall lecture 01 §2's "Why It Matters": "the loop's termination condition is purely structural... does the most recent assistant turn contain any tool_use blocks?") — agentLoop is done: exit the function immediately, with no value to return. Control goes back to whatever called agentLoop (eventually main(), lecture 06).
If results has at least one entry — send them all back in a single new message, not one message per result: one entry playing the user role (the API's shape — the assistant asks, the harness/"user" answers), whose content is the entire results array. Then loop again, back to the top of §2's step 1 — call the model, with the now-longer messages.
That "single message" detail matters and is easy to get subtly wrong: if the model's last turn asked for three tools in one go, you send back one new message whose content holds all three results together — not three separate messages, one per result. This mirrors §2's "push the assistant's whole reply verbatim as one message" exactly, just on the harness's side of the exchange instead of the model's: one turn, one message, possibly many blocks inside it.
Why It Matters
This is the exact rhythm lecture 04 §2's unlock question previewed before you'd written a line of agentLoop: "every function on the call path... ends up async... for the same one reason repeated at each link." Here's where that chain actually closes the loop — literally. agentLoop is async because it awaits model(...) and (inside §3) awaits confirm(...); the while (true) around all of it is what turns "one exchange" into "a whole conversation," continuing exactly as long as the model keeps asking for tools and stopping the instant it doesn't.
It's also the reason this function mutates its caller's array instead of returning a new one. Look at agentLoop's return type again: Promise<void> — not Promise<Anthropic.MessageParam[]>. It never returns the updated conversation; it mutates the messages array it was handed, via .push(...), in place. That's deliberate, and it's not just an implementation detail — lecture 06's REPL depends on exactly this behavior: main() will keep one messages array alive across multiple calls to agentLoop (one per line you type at the prompt), and it only works because each call appends to the same array rather than handing back a fresh one main() would have to remember to reassign. If agentLoop built and returned a new array instead of mutating the one it was given, the REPL's persistence across turns would require an extra assignment at every call site — a small thing, but not what the skeleton's signature (Promise<void>, no return value) is set up for.
Hint: The check that decides "loop again or return" is a single condition onresults.length— nothing fancier (no separate boolean flag tracked across thefor...offrom §3, no counting text blocks). Whatever you push ontomessagesfor the "loop again" branch is one message, not aforloop that pushes once per entry inresults.
Common mistake: Pushing theresultsarray's entries ontomessagesone at a time (for (const r of results) messages.push({ role: "user", content: [r] })) instead of once, as a single message whosecontentis the whole array. Watch closely here: every scripted response intests/05_agent_loop.test.tshappens to contain exactly onetool_useblock per turn — so with only one result to push either way, this bug produces identical output to the correct version, and./ok 05would not catch it. This is exactly the kind of gap lecture 03 §2 warned you about withwrite_file's untested return value: passing every test in the file isn't the same claim as being correct for every input the model could send. A model that asks for two tools in one turn (which real Claude Code does routinely) would expose the bug immediately; nothing in this lecture's suite happens to ask for that.
Your Task — Unlock Questions
Spec. Implement the last piece of agentLoop, in index.ts: after building results in §3, check whether it's empty. If it is, exit the function with no value — the loop ends. Otherwise, append one new entry to messages — user role, content the entire results array — and loop back to the top (§2's model call) with the now-longer messages.
Worked example. Mid-loop, after §2 and §3 have each already run once: messages holds a user entry ("list files") followed by an assistant entry (a tool_use for list_files) — two entries so far — and results from §3 holds one tool_result for that call. This stage appends a third entry: user role, content that single-item results array. Then it loops. If the model's second reply this time contains no tool_use blocks, this stage instead returns without appending a fourth entry at all — leaving messages at exactly three entries: user, assistant, user.
Before checking the Appendix, answer these using the shape you've now fully built across §§2–4:
- Predict the messages array.
messagesstarts as[{ role: "user", content: "explore" }]. The scripted model returns, in order: (1) atool_useforlist_files, (2) atool_useforread_file, (3) plaintextwith no tool calls. AfteragentLoopreturns, how many entries doesmessageshave, and what is theroleof each one, in order? (This is exactlytests/05_agent_loop.test.tstest (e) — you can check your answer against the test file directly, not just the Appendix.) - Predict a denial's effect on message count. A model asks for one gated
bashcall; the human answers"n". Does the denial change how many entries get pushed tomessagescompared to an approvedbashcall that succeeds — or does the message-count shape stay identical either way, only thecontent/is_errorfields inside thetool_resultdiffering? - The mutation question. Suppose, instead of
messages.push(...), your implementation wrotemessages = messages.concat([...])at each step (reassigning the local parameter to a new array rather than mutating the original)../ok 05's tests all inspect the samemessagesarray reference they originally created and passed in, afterawait agentLoop(...)resolves. Would that reassignment-based version pass those tests? Why or why not? - Termination, precisely. Under what exact condition does
agentLoopdecide to stop looping — and is that condition checked before or after you've already pushed the assistant's turn ontomessages?
Then implement it: write this stage — the return-or-loop-again decision — completing agentLoop.
Checkpoint
Run the full suite:
./ok 05
Expected, once callModel and agentLoop are both correctly implemented (and lectures 02–04's work is already green, which the coupling notes assume):
ok: running tests for problem 05
RUN v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano
Test Files 1 passed (1)
Tests 5 passed (5)
All five tests green: the text-only single-call case, the tool-execution-and-feedback case, the self-correction-on-error case, the gated-denial case, and the two-round alternation case. That's Problem 5.2 done, and — combined with 5.1 — 2.5 of the course's 10 points, the largest single lecture on the board.
Stuck? See Snippet 4 in the Appendix.
5. Back to Lecture 01: You Predicted This Transcript Already
Concept
Reopen lecture 01 §3 — the full trace of Nano fixing the Celsius/Fahrenheit bug in fixtures/convert.js. Six entries in messages, three calls to callModel, one human "y". At the time you first read that trace, none of runTool, confirm, or agentLoop existed — you were staring at a prediction of what code you hadn't written yet would produce. Every mechanical rule that trace relied on is now code you personally wrote:
messages[1]holding the assistant's reply verbatim — its whole content array, unmodified — §2 of this lecture.messages[2]'stool_resultwithtool_use_id: "toolu_01A"— §3 of this lecture, the id that matches the result back to its request, copied straight offblock.id.edit_fileinmessages[3]going through a human "y" beforemessages[4]shows its result — §3's gate check,GATED.has(block.name)andawait confirm(...), in that order.- The loop stopping after
messages[5](atext-only assistant turn, notool_use) — §4'sif (results.length === 0) return;.
Why It Matters
This is worth pausing on, not skipping past. The reason lecture 01 spent an entire lecture on zero real code was so that today, the shape of correct output would already be familiar before you had any code capable of producing it. If ./ok 05 passes, your agentLoop — run against a scripted model instead of the real API, but structurally identical in every way that matters — generates conversations with exactly this shape: assistant turns pushed verbatim, tool results matched by id, gated tools pausing for confirmation, the loop ending the instant a turn comes back tool-call-free. Four lectures ago, this was a diagram you annotated by hand. Now it's the behavior of code you wrote and tested yourself.
Your Task
No new code. Reread lecture 01 §3's trace one more time, now that you've built the function that produces it, and check: does anything in that trace rely on a rule your agentLoop doesn't actually enforce? If you find a gap, that's worth revisiting before lecture 06 — it means either your implementation or your original prediction had a hole in it.
Checkpoint
There shouldn't be a gap, if ./ok 05 is green — the five tests in tests/05_agent_loop.test.ts collectively exercise every rule the lecture 01 trace depends on: verbatim assistant pushes (all five tests), tool_use_id matching (test (b)), is_error on thrown failures (test (c)), gated denial without execution (test (d)), and multi-round alternation (test (e)). If all five pass, you've mechanically verified every claim lecture 01 made you predict by hand.
Stuck? See Snippet 5 in the Appendix.
Key Takeaways
callModelis the one function that leaves your machine, and it's built to be lazy — the client is constructed only on first use, via the assignment form of??, not at module load — so that importingindex.ts— which every test file does — never requires a real API key. No test in this course calls it directly; that absence is itself the lesson.
agentLoop'smodelparameter is an injectable seam, the same pattern as lecture 04'sAsker: a default value (= callModel) for real use, overridable by a scripted fake for tests. This is what makes the boss-level function fully testable offline.
- One trip around the loop is exactly three moves: call the model; push its reply verbatim (never edited, never filtered — the model must see its own requests, unmodified, on the next call); walk the content blocks and build one
tool_resultpertool_use.
- Every
tool_useresolves through the same three-way branch: gated-and-denied → a "declined" result withis_error: false; ungated-or-approved-then-thrown → the caught error's message withis_error: true; ungated-or-approved-then-succeeded → the return value withis_error: false. No tool gets special-cased — the branch is uniform becauseGATEDand the throw-on-failure convention were built once, in earlier lectures, specifically soagentLoopwouldn't need to know the difference betweenbashandread_file.
- Tool results ride back as one new message playing the user role, whose content is the entire results array — not one message per result — the mirror image of pushing the assistant's whole turn as one message. The loop terminates the instant a turn produces zero
tool_useblocks; that check is structural, not something the model explicitly signals.
agentLoopmutates its caller'smessagesarray rather than returning a new one (Promise<void>, notPromise<MessageParam[]>) — a design choice lecture 06's REPL depends on: one long-lived array, persisted across many calls toagentLoop, one call per line typed at the prompt.
- You predicted this exact output four lectures ago. Lecture 01 §3's trace wasn't a warm-up exercise disconnected from the rest of the course — it was the specification
agentLoophad to satisfy, written down before any of the code existed to satisfy it.
What we skipped (for future study or future lectures)
main()and the readline REPL that actually drivesagentLoopfrom a real terminal, plus crash recovery (popping a dangling assistant message) — lecture 06, in full.- Streaming responses —
agentLoopalways waits for one completeAnthropic.Messagebefore acting; a streaming version would need to handle partial content blocks arriving incrementally, a meaningfully different shape than this course covers (curriculum: explicitly out of scope). - Anything about what happens if
model(messages)itself throws (a network error, a malformed response) — not exercised by this lecture's tests, and not handled specially insideagentLoopitself; lecture 06'smain()is where a top-leveltry/catcharound the whole REPL loop catches this class of failure. - Multiple tool calls executed concurrently instead of one after another — this course's loop processes
tool_useblocks sequentially within a turn (a plainfor...of), which is simpler and sufficient for everything this course tests, though a production agent might parallelize independent reads. - Token/context limits, conversation truncation, or summarizing old turns to keep
messagesfrom growing unbounded — real Claude Code has to deal with this; this course's toy conversations never get long enough to need it (curriculum: "context compaction... v2 territory").
Appendix: Reference Implementation
Check your work here after attempting each section. Don't read ahead — one snippet at a time.
Snippet 1: callModel
export async function callModel(messages: Anthropic.MessageParam[]): Promise<Anthropic.Message> {
client ??= new Anthropic(); // reads ANTHROPIC_API_KEY from the environment
return client.messages.create({
model: MODEL,
max_tokens: 8192,
system: SYSTEM_PROMPT,
tools: TOOLS, // the schemas from section 1 — this is how the model knows what it can ask for
messages,
});
}
Snippet 1: callModel — unlock answers
new Anthropic()at module scope would run the instantindex.tsis imported — which happens at the top of every single test file in this course (import { agentLoop, ... } from "../index.ts"), including test runs whereANTHROPIC_API_KEYis unset entirely (CI, a fresh clone,./okrun offline as theokscript's own comment promises). Depending on the SDK version, constructing a client with no key present may throw immediately or otherwise misbehave at import time — before a single test even runs. Every test file in the project would be at risk of failing before its first assertion, for a reason with nothing to do with what that test file is actually checking. Lazy construction defers that risk to the one code path (a real call tocallModel) that actually needs a key.- A default parameter is overridable by any caller who supplies their own third argument — exactly what every test in
tests/05_agent_loop.test.tsdoes, passingmodel.fn(ascriptedModel's function) instead of accepting the default. A hardcoded internal reference tocallModel(e.g. calling it directly insideagentLoop's body with no parameter at all) would give test code no way to substitute a fake — every test would need a real API key and network access, which is precisely what this course's entire test suite is built to avoid. tools: TOOLSinsidecallModel, the line built in this lecture. Every prior lecture read, described, or reasoned aboutTOOLSas a contract; none of them ever transmitted it anywhere, becausecallModel— the only function that talks to the API — didn't exist until now.
Snippet 2: One-iteration skeleton
export async function agentLoop(
messages: Anthropic.MessageParam[],
ask: Asker,
model: (messages: Anthropic.MessageParam[]) => Promise<Anthropic.Message> = callModel,
): Promise<void> {
while (true) {
const response = await model(messages);
// The assistant's turn joins the history VERBATIM — including its
// tool_use blocks. The model must see its own requests next turn.
messages.push({ role: "assistant", content: response.content });
// ... walk response.content and build tool_results (Snippet 3) ...
// ... decide whether to loop again or return (Snippet 4) ...
}
}
Snippet 3: Executing one tool_use block
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "text") console.log(`\n${block.text}`);
if (block.type !== "tool_use") continue;
console.log(`● ${block.name}(${JSON.stringify(block.input).slice(0, 120)})`);
let content: string;
let is_error = false;
if (GATED.has(block.name) && !(await confirm(ask, block.name, block.input))) {
// A denial is just information, sent back like any other result.
content = "The user declined this action. Ask before retrying, or try another approach.";
} else {
try {
content = runTool(block.name, block.input);
} catch (err: any) {
// Errors go BACK TO THE MODEL — watch it read them and self-correct.
content = String(err?.message ?? err);
is_error = true;
}
}
results.push({ type: "tool_result", tool_use_id: block.id, content, is_error });
}
Snippet 3: Executing one tool_use block — unlock answers
confirmbeforerunTool, andrunToolmust never run at all on a denial — not "run, then discard." CallingrunTooland throwing away its output would still let abashcommand's real side effect (creating the sentinel file) happen on disk; test (d) checks the file's existence, which only staysfalseifrunToolis genuinely never invoked in the denied branch.- It would fail as an uncaught exception propagating straight out of the
await agentLoop(...)line inside the test itself — the test would never reach itsexpect(tr.is_error).toBe(true)orexpect(messages.at(-1)!.role).toBe("assistant")assertions at all, because the function under test crashed before returning. That's a strictly worse failure mode than a clean assertion mismatch: it tells you the function isn't just wrong, it's not resilient to the exact failure mode (a thrown tool error) lecture 02 §5 told youagentLoopexists to convert into ordinary data.
Snippet 4: Full agentLoop
export async function agentLoop(
messages: Anthropic.MessageParam[],
ask: Asker,
model: (messages: Anthropic.MessageParam[]) => Promise<Anthropic.Message> = callModel,
): Promise<void> {
while (true) {
const response = await model(messages);
// The assistant's turn joins the history VERBATIM — including its
// tool_use blocks. The model must see its own requests next turn.
messages.push({ role: "assistant", content: response.content });
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === "text") console.log(`\n${block.text}`);
if (block.type !== "tool_use") continue;
console.log(`● ${block.name}(${JSON.stringify(block.input).slice(0, 120)})`);
let content: string;
let is_error = false;
if (GATED.has(block.name) && !(await confirm(ask, block.name, block.input))) {
// A denial is just information, sent back like any other result.
content = "The user declined this action. Ask before retrying, or try another approach.";
} else {
try {
content = runTool(block.name, block.input);
} catch (err: any) {
// Errors go BACK TO THE MODEL — watch it read them and self-correct.
content = String(err?.message ?? err);
is_error = true;
}
}
results.push({ type: "tool_result", tool_use_id: block.id, content, is_error });
}
// No tool requests → the model is done. Hand control back to the REPL.
if (results.length === 0) return;
// Tool results are sent as a *user* message — that's the API's shape:
// assistant asks, "user" (us, the harness) answers.
messages.push({ role: "user", content: results });
}
}
Snippet 4: Full agentLoop — unlock answers
- Six entries, roles in order:
["user", "assistant", "user", "assistant", "user", "assistant"]. Walking it:messages[0]is the original"explore"prompt; call #1 returns thelist_filestool_use, pushed asmessages[1](assistant); its result is pushed asmessages[2](user); call #2 returns theread_filetool_use, pushed asmessages[3](assistant); its result is pushed asmessages[4](user); call #3 returns plain text with no tool calls, pushed asmessages[5](assistant), and since that turn produced zerotool_useblocks,results.length === 0and the loop returns without pushing a seventh entry. This is preciselytests/05_agent_loop.test.tstest (e). - The shape stays identical either way — one
tool_usein, onetool_resultout, regardless of whether that result represents an approval-and-success or a denial. A deniedbashcall still produces exactly onetool_resultentry inresults, still gets pushed inside exactly one{ role: "user", content: results }message, same as an approved-and-successful one. Only thecontentstring and the fact thatis_errorstaysfalseeither way (a denial isn't an error — lecture 04 §6) differ; the message-count shape is invariant to which of the three branches (denied / succeeded / threw) a given call took. - No, it would not pass — or at least not for the reason the test author intended.
tests/05_agent_loop.test.ts's assertions (e.g.expect(messages).toHaveLength(2),expect(messages.map((m) => m.role)).toEqual([...])) all inspect the same array reference the test itself created and passed intoagentLoopas the first positional argument, then check it afterawait agentLoop(...)has resolved — with no reassignment on the test's side. IfagentLoop's body reassigned its localmessagesparameter to a new array via.concat(...)instead of mutating in place via.push(...), that reassignment only rebinds the local variable insideagentLoop's own scope; the original array the test is holding a reference to never gets any new entries appended to it at all. The test would see the original, un-grown array — likely still just the single initial entry — and every length/role assertion would fail. agentLoopstops exactly when a turn'sresultsarray is empty, i.e. the model's most recent turn contained zerotool_useblocks — checked after that turn's assistant message has already been pushed ontomessages(§2's step 2 always runs before this check), but before anything would be pushed for a next round that, in this case, never happens. This is the same structural (not semantic) termination rule lecture 01 §2 first stated: the harness infers "done" from the shape of the last turn, not from any explicit signal the model has to emit.
Snippet 5: Section 5 has no new code
There's nothing to implement here — §5 is a verification exercise, not a coding problem. If ./ok 05 is green, every rule lecture 01 §3's trace depended on is one your agentLoop now enforces: verbatim assistant pushes (Snippet 2), tool_use_id → tool_result.tool_use_id matching (Snippet 3, the tool_use_id: block.id line), the gate running before runTool (Snippet 3's if ordering), thrown errors becoming is_error: true results (Snippet 3's catch branch), and structural termination on a tool-call-free turn (Snippet 4's if (results.length === 0) return;). Nothing about lecture 01's trace was aspirational — it was, this whole time, agentLoop's spec.
End of lecture.