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 on client, 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_tokens is 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's input.path ?? "."): tested against client and undefined, it assigns a fresh client only the first time, and leaves client alone 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:

  1. If callModel constructed new Anthropic() at the top of the file (module scope), instead of lazily inside the function body, what specifically would break about running ./ok in this course — walk through what happens the instant any test file's import { agentLoop } from "../index.ts" line executes.
  2. agentLoop's parameter is written model: (...) => Promise<Anthropic.Message> = callModel, not model: (...) => Promise<Anthropic.Message> with callModel chosen 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"?
  3. Lecture 02 spent an entire section (§2) establishing that TOOLS's description fields are read only by the model, never by your TypeScript. Where, concretely, does TOOLS actually 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:

  1. Call the model. Await the injected model parameter, passing it the current messages — not callModel directly by name, which is exactly what makes the whole function testable (§1). What comes back is an Anthropic.Message whose content field is the array of blocks (text, tool_use, or a mix of both) the model produced this turn.
  2. Push the assistant's reply, verbatim. Take that content array exactly as the API returned it — not reformatted, not filtered, not summarized — and append it to messages as one new entry playing the assistant role. Whatever mix of blocks the model produced goes in unmodified.
  3. Look at what it asked for. Walk the content blocks and find any tool_use blocks — 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:

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 with arr && arr.length to avoid touching .length on something that might not exist. Only tools where the first check passes ever reach the await confirm(...) call at all.
Common mistake: Calling confirm for every tool, then checking GATED.has(...) only to decide whether to look at its answer. This means an ungated call like read_file would sit there awaiting a human's y/n for no reason — annoying in a real REPL, and a guaranteed hang against noAsker in tests (b), (c), and (e), whose Asker is deliberately built to throw new Error("gate should not have been consulted") the instant question is called. Those three tests exist specifically to catch a confirm call 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):

  1. Test (d) passes scriptedAsker(["n"]) and a bash call whose command would create a sentinel file. The test asserts fs.existsSync(sentinel) is false after agentLoop finishes. What does that prove about the order of your two checks — does confirm have to be called before runTool, or could you call runTool first and just discard its result if confirm says no?
  2. Test (c) scripts read_file on a missing path and asserts tr.is_error is true and messages.at(-1)!.role is "assistant" (i.e., the loop kept going for a second call). If your agentLoop implementation let a thrown runTool exception propagate all the way out of agentLoop itself — no try/catch at 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

  1. confirm must be called before runTool, and if it returns false, runTool must never be called at all — not "called and its result discarded." Calling runTool("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.
  2. The whole test would fail differently and worse: an uncaught exception thrown from deep inside agentLoop would propagate out of the await agentLoop(...) call in the test itself, and the test would fail with that raw ENOENT error as an uncaught rejection/exception, never even reaching the expect(...) assertions on tr.is_error or messages.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 the try/catch lecture 02 §5 told you agentLoop would 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 on results.length — nothing fancier (no separate boolean flag tracked across the for...of from §3, no counting text blocks). Whatever you push onto messages for the "loop again" branch is one message, not a for loop that pushes once per entry in results.
Common mistake: Pushing the results array's entries onto messages one at a time (for (const r of results) messages.push({ role: "user", content: [r] })) instead of once, as a single message whose content is the whole array. Watch closely here: every scripted response in tests/05_agent_loop.test.ts happens to contain exactly one tool_use block per turn — so with only one result to push either way, this bug produces identical output to the correct version, and ./ok 05 would not catch it. This is exactly the kind of gap lecture 03 §2 warned you about with write_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:

  1. Predict the messages array. messages starts as [{ role: "user", content: "explore" }]. The scripted model returns, in order: (1) a tool_use for list_files, (2) a tool_use for read_file, (3) plain text with no tool calls. After agentLoop returns, how many entries does messages have, and what is the role of each one, in order? (This is exactly tests/05_agent_loop.test.ts test (e) — you can check your answer against the test file directly, not just the Appendix.)
  2. Predict a denial's effect on message count. A model asks for one gated bash call; the human answers "n". Does the denial change how many entries get pushed to messages compared to an approved bash call that succeeds — or does the message-count shape stay identical either way, only the content/is_error fields inside the tool_result differing?
  3. The mutation question. Suppose, instead of messages.push(...), your implementation wrote messages = 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 same messages array reference they originally created and passed in, after await agentLoop(...) resolves. Would that reassignment-based version pass those tests? Why or why not?
  4. Termination, precisely. Under what exact condition does agentLoop decide to stop looping — and is that condition checked before or after you've already pushed the assistant's turn onto messages?

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:

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

  1. callModel is 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 importing index.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.
  1. agentLoop's model parameter is an injectable seam, the same pattern as lecture 04's Asker: 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.
  1. 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_result per tool_use.
  1. Every tool_use resolves through the same three-way branch: gated-and-denied → a "declined" result with is_error: false; ungated-or-approved-then-thrown → the caught error's message with is_error: true; ungated-or-approved-then-succeeded → the return value with is_error: false. No tool gets special-cased — the branch is uniform because GATED and the throw-on-failure convention were built once, in earlier lectures, specifically so agentLoop wouldn't need to know the difference between bash and read_file.
  1. 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_use blocks; that check is structural, not something the model explicitly signals.
  1. agentLoop mutates its caller's messages array rather than returning a new one (Promise<void>, not Promise<MessageParam[]>) — a design choice lecture 06's REPL depends on: one long-lived array, persisted across many calls to agentLoop, one call per line typed at the prompt.
  1. 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 agentLoop had to satisfy, written down before any of the code existed to satisfy it.

What we skipped (for future study or future lectures)


Appendix: Reference Implementation

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

Snippet 1: 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
  1. new Anthropic() at module scope would run the instant index.ts is imported — which happens at the top of every single test file in this course (import { agentLoop, ... } from "../index.ts"), including test runs where ANTHROPIC_API_KEY is unset entirely (CI, a fresh clone, ./ok run offline as the ok script'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 to callModel) that actually needs a key.
  2. A default parameter is overridable by any caller who supplies their own third argument — exactly what every test in tests/05_agent_loop.test.ts does, passing model.fn (a scriptedModel's function) instead of accepting the default. A hardcoded internal reference to callModel (e.g. calling it directly inside agentLoop'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.
  3. tools: TOOLS inside callModel, the line built in this lecture. Every prior lecture read, described, or reasoned about TOOLS as a contract; none of them ever transmitted it anywhere, because callModel — 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
  1. confirm before runTool, and runTool must never run at all on a denial — not "run, then discard." Calling runTool and throwing away its output would still let a bash command's real side effect (creating the sentinel file) happen on disk; test (d) checks the file's existence, which only stays false if runTool is genuinely never invoked in the denied branch.
  2. It would fail as an uncaught exception propagating straight out of the await agentLoop(...) line inside the test itself — the test would never reach its expect(tr.is_error).toBe(true) or expect(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 you agentLoop exists 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
  1. Six entries, roles in order: ["user", "assistant", "user", "assistant", "user", "assistant"]. Walking it: messages[0] is the original "explore" prompt; call #1 returns the list_files tool_use, pushed as messages[1] (assistant); its result is pushed as messages[2] (user); call #2 returns the read_file tool_use, pushed as messages[3] (assistant); its result is pushed as messages[4] (user); call #3 returns plain text with no tool calls, pushed as messages[5] (assistant), and since that turn produced zero tool_use blocks, results.length === 0 and the loop returns without pushing a seventh entry. This is precisely tests/05_agent_loop.test.ts test (e).
  2. The shape stays identical either way — one tool_use in, one tool_result out, regardless of whether that result represents an approval-and-success or a denial. A denied bash call still produces exactly one tool_result entry in results, still gets pushed inside exactly one { role: "user", content: results } message, same as an approved-and-successful one. Only the content string and the fact that is_error stays false either 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.
  3. 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 into agentLoop as the first positional argument, then check it after await agentLoop(...) has resolved — with no reassignment on the test's side. If agentLoop's body reassigned its local messages parameter to a new array via .concat(...) instead of mutating in place via .push(...), that reassignment only rebinds the local variable inside agentLoop'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.
  4. agentLoop stops exactly when a turn's results array is empty, i.e. the model's most recent turn contained zero tool_use blocks — checked after that turn's assistant message has already been pushed onto messages (§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.