Subagents: The Task Tool
Lecture Overview
Six lectures built one loop: every tool call resolves the same way — call something, get a string or a boolean back, keep going. Nothing in index.ts has ever spawned new work of its own. A sixth tool changes that, and the idea behind it is this: an agent that can use tools can be a tool. Not a new subsystem bolted onto agentLoop — the same function, called on itself. It requires no scheduler, no message bus, no second codebase — just one tool_use branch that, instead of calling runTool, calls agentLoop, recursing into the function whose stub you filled in back in lecture 05. Everything hard about subagents was already solved the moment model and ask became parameters instead of hardcoded globals; today just aims those parameters one layer inward.
A subagent works because it starts from nothing. Lecture 01 established that messages is the model's only memory — it re-reads the whole array from scratch on every call. A subagent takes that rule and turns it into a design decision: hand it the entire parent conversation and it isn't a subagent at all, just the parent continuing to talk to itself with extra steps; hand it nothing but one self-contained instruction and it becomes an independent worker that explores on its own and reports back one clean answer. This is a bonus lecture, worth +1 of the course's 10 points — 11 out of 10 if you complete it — and it supersedes lecture 06's extra challenge 3, which described a task tool in prose and left it with no test, no appendix, and the warning that "tracing exactly how far \[async-ness] ripples is most of the actual challenge." Today that challenge gets a real spec, a real test suite, and a real answer key.
How this lecture works: three short concept sections — recursion, context isolation, and why the branch lives in agentLoop rather than runTool — each end in a predict-don't-code task, then one real problem, 7.1, checked by ./ok 07. A closing section covers what this subagent tool doesn't do, and the lecture ends with the course's final score.
1. Recursion as Delegation: The Conversation Becomes a Tree
Concept
Every lecture so far has treated messages as one thing: a single flat, append-only array, the whole conversation, start to finish. That was never wrong — it's still true of the parent conversation after today. What changes is that a single tool_use block can now ask the harness to do something no other tool has ever asked for: don't just run a function and hand back a string — run an entire nested conversation, start to finish, and hand back whatever it decided.
Once a tool_use block can trigger a whole separate messages array with its own sequence of model calls, tool calls, and results, the flat array from lecture 01 stops being the only one in play. The parent keeps its own array unchanged; every task call builds a completely separate array for the subagent, and that subagent's own array could itself contain a task call that builds another separate array one level deeper. Nothing about any single array is new — every array, parent or subagent, is still exactly the append-only log lecture 01 taught you to read. There can just be more than one of them now, and the parent's array picks up the subagent's result through one tool_result.
The important thing to notice is what actually executes a task call. It isn't a new "subagent runner" you write from scratch — it's agentLoop itself, called again, on a different array. You already wrote 100% of the mechanism a subagent needs, back in lecture 05, without knowing it would eventually be asked to run on more than one array in the same program.
Why It Matters
This is the closest this course comes to teaching "an agent framework" as a concept, and it costs you zero new agent-shaped code to understand it, because there isn't any — a subagent is not a different kind of thing than the agent you've already built, it's the same thing, invoked recursively. Compare this to lecture 06 §7's extra challenge 3, which described exactly this idea in prose but left you to invent the ripple effects yourself: "this is not a new concept at all — it's list_files's 'the model recurses by calling again, one level at a time' idea from lecture 02, just one layer higher: instead of the model deciding to call a tool again, the harness is asked to run an entire nested copy of itself." Today's lecture is that same sentence, turned into a spec you can actually implement and check.
Your Task
No code yet — predict. A task tool_use block shows up exactly the way any other tool_use block would, inside the assistant turn agentLoop's §2-and-§3 machinery is already walking. Suppose the branch that handles it resolves the subagent by awaiting a fresh call to agentLoop itself. While that nested call is running — before it has finished — what state is the parent's own agentLoop invocation in: has its stack frame exited, or is something else going on? Reach back to lecture 04's await framing (§2 of that lecture) before answering.
Checkpoint
Paused, not exited — the exact same suspend-and-resume await has performed every time it's shown up in this course, from await confirm(...) waiting on a human to await model(messages) waiting on a network call. awaiting a recursive call to agentLoop is not a special new control-flow feature; it's the identical mechanism, aimed at a call to the very function currently running. The parent's own iteration of its while loop is alive the whole time, simply suspended — and it resumes on the very next line only once that nested call's promise settles, which (by agentLoop's own termination rule) means only once the entire subagent conversation has run to completion with no tool calls left pending. Nothing about "the loop calling itself" requires new machinery you haven't already used.
Stuck? See Snippet 1 in the Appendix.
2. Context Isolation: Why the Subagent's First Call Sees Exactly One Message
Concept
TOOLS in index.ts already gives you the task schema in full — this lecture didn't ask you to write a word of it, the same way lecture 01 gave you TOOLS for the first five tools. Read its description field the way lecture 02 taught you to read every tool description: as a contract the model reads, never your TypeScript. It says the subagent "starts with NO shared context — it sees none of this conversation — and returns only its final answer," and instructs the model to "put EVERYTHING it needs into the prompt: the goal, any relevant paths, and exactly what to report back." That sentence isn't flavor text sitting next to the real contract — it is the contract, and the model is told this directly, every time it considers calling task.
Why build it this way? Imagine the alternative: a subagent that starts from a copy of the parent's entire messages array — every prior user request, every tool call, every result, all of it. What would that subagent functionally be? Not a delegate at all — just the parent, continuing to talk to itself, with a second thread of the same history running in parallel for no benefit. There would be nothing "sub" about it. The reason a subagent is worth having is precisely that it doesn't drag the parent's history along: it gets one narrow, purpose-built starting point, does whatever exploring it needs to do entirely on its own, and reports back a single condensed answer — not a transcript of everything it looked at along the way.
That's also why isolation is what lets delegation scale, rather than being a restriction you tolerate. A subagent might read a dozen files and run several commands before it's satisfied — none of that internal back-and-forth ever touches the parent's own messages array. The parent's context stays exactly as small as one tool_result, no matter how much work happened underneath it. Real Claude Code's own Task tool works on this same principle: a subagent explores independently and reports one final, distilled result back — that's what makes "go investigate this whole area of the codebase and summarize it" a request you can hand off cheaply, instead of one that bloats the parent conversation with every file the investigation happened to touch.
Why It Matters
Isolation has a sharp, unglamorous consequence: whatever text the model writes into task's prompt field is the entirety of what the subagent will ever know. A prompt like "check on the thing we discussed earlier" would be nonsense to a subagent that never saw "earlier" happen at all — it has no prior turns to consult, no way to ask the parent's original human a clarifying question, nothing except the literal string it was handed. This isn't a hypothetical edge case the schema warns about for completeness; it's the single most common way a task call goes wrong in practice, and it's exactly why the schema's description spells out "put EVERYTHING it needs into the prompt" instead of assuming the model will figure that out on its own.
Your Task
No code yet — predict. A parent conversation has five real turns of history behind it — prior questions, prior tool calls, prior results. The model then calls task with the prompt "check whether utils.js still has the bug we found." When the subagent's very first call to the model goes out, what does that call's messages array actually contain — and does the subagent have any way to know what "the bug we found" refers to?
Checkpoint
Exactly one entry: a plain user turn whose content is that literal string, "check whether utils.js still has the bug we found." — nothing about the five prior turns, no memory of any bug ever being located, none of the parent's own words anywhere in it. "The bug we found" is a dangling reference the subagent has no way to resolve; it isn't handed a summary, a hint, or a pointer to go look one level up — its whole world, at that first call, is the one sentence it was given. This is context isolation's sharpest edge: it doesn't fail loudly. The subagent will confidently attempt to answer some question — just not necessarily the one the human upstream actually meant.
Stuck? See Snippet 1 in the Appendix.
3. Same Seam, One Layer Deeper: Why the Branch Lives in agentLoop, Not runTool
Concept
Every one of the five original tools resolves through runTool — a plain, synchronous function that lecture 02's dispatch-table framing deliberately kept model-blind and gate-blind. runTool has never, in six lectures, taken an Asker, called confirm, or known that a permission gate exists at all; it just runs one named function and hands back a string, or throws. That narrowness was the whole point of building it that way — it let every later lecture add machinery (the gate in 04, the loop in 05) around runTool without ever touching its inside.
A subagent cannot be resolved through that same narrow function, and it's worth being precise about why. Running a subagent to completion means driving an entire nested conversation — its own model calls, possibly its own gated tool calls a real or scripted human has to weigh in on, possibly several rounds of back-and-forth before it's done. That requires exactly the things agentLoop already carries in its own parameter list: the injected model function (so a subagent's calls are just as fake-able in a test, and just as real in production, as the parent's), the Asker (so a gated call inside a subagent still asks somebody, not nobody), and the ability to await something that isn't a microsecond operation. None of that fits inside runTool's signature — a plain (name, input) => string. All of it is already sitting, unused for this purpose until today, in agentLoop's own parameters, because agentLoop already needed every one of them for itself.
Why It Matters
This is the seam argument from lectures 04 and 05 paying off a third time, and it's worth naming the pattern explicitly since it keeps recurring: build a function to depend on the narrowest interface it needs, passed in as a parameter, rather than reaching out and grabbing a concrete real-world resource directly — and every later feature that needs that same resource gets it for free, by reusing the parameter that's already there. confirm didn't need to know it would eventually run inside a recursive call when lecture 04 built it around a bare Asker; agentLoop didn't need to know a task tool existed yet when lecture 05 made model and ask its own parameters instead of module-level constants. Recursing into agentLoop with the same model and the same ask you already have in scope means a subagent's model calls hit the identical scripted queue a test set up, and a subagent's gated tool calls consult the identical confirm you built in lecture 04 — nothing new has to be threaded through, because the seam was already open.
This is also, concretely, why ./ok 07 can test something as elaborate as "a nested conversation, with its own tool call, gated behind its own permission check" entirely offline, with no real API key and no real terminal. If the task branch instead constructed its own fresh connection to the real world — a brand-new readline interface, say, instead of reusing the ask already in hand — a scripted test would have no way to reach it. Sharing model and ask down the recursion isn't a nicety; it's the entire reason a subagent's behavior is exercisable by a queue of canned responses, the same trick that has kept every lecture since 05 fast and free.
Common mistake to watch for going into Problem 7.1: building a freshAsker(or a fresh model function) for the subagent instead of passing along the onesagentLoopalready received as parameters. It's a natural instinct — "the subagent needs its own things" — but it's backwards here: the subagent needs the same things, precisely so the same test infrastructure, and the same human, stay in the loop no matter how deep the recursion goes.
Your Task
No code yet — predict. Suppose the task branch, instead of reusing the ask parameter already in scope, built some other stand-in for a human — say, a hardcoded object that always approves. What would you expect to happen to a test that scripts a denial ("n") for a gated tool call made inside a subagent, and checks that the corresponding side effect (a file that command would have created) never happened on disk?
Checkpoint
That test would fail — and fail in the worst way, silently. A gated bash call inside the subagent would sail through an always-approving stand-in, actually execute, and leave the side-effect file sitting on disk exactly where the test asserts it must be absent. The failure wouldn't look like a crash or a hang; it would look like the subagent simply not respecting a "no" it was never actually given the chance to hear, because the branch never routed its question to the one Asker the test was scripting answers through in the first place. Reusing the exact ask reference already in scope is what makes the gate real at every depth of recursion, not just at the top.
Stuck? See Snippet 1 in the Appendix.
4. Reading the Answer Back Out: agentLoop Still Returns void
Concept
Recall lecture 05 §4's "Why It Matters," which called out something that looked, at the time, like a small implementation detail: agentLoop's return type is Promise<void>, not Promise<MessageParam[]> — it never hands back the conversation it just built; it mutates the caller's array in place, via .push(...). That was introduced to explain why main()'s REPL can keep calling agentLoop on the same long-lived array across many lines typed at the prompt without an extra reassignment at every call site.
Today that same property does a second job. When the task branch awaits a recursive call to agentLoop, that call resolves to undefined — exactly like every other call to agentLoop always has. There is no separate value coming back to catch. Whatever the subagent decided, said, read, or wrote now lives only inside the one array you built and handed to that recursive call before it started — the same array reference, mutated some number of times by everything the subagent did, still sitting in your own local scope once the await resolves.
Why It Matters
This is why the array you build for the subagent can't be something you construct and then discard — it is the channel the answer travels back through, in exactly the sense lecture 05 called this mutation "load-bearing": a promise back then, a fact you're relying on directly today. Without it, the task branch would have no way at all to learn what its subagent produced, since the recursive call's own return value carries nothing. The answer isn't returned to you — it's found, by looking at whatever the mutated array holds once the recursive call settles.
Specifically: look at the last entry. Lecture 05 §4's termination rule guarantees what role that entry has to be. agentLoop's own while loop only ever returns once a turn's collected results are empty — and by construction, it only reaches that check immediately after having just pushed that same turn onto the array as an assistant entry. There's no path through the loop you wrote in lecture 05 that returns with anything other than an assistant turn sitting last.
Your Task
No code yet — predict. After a recursive call to agentLoop on a freshly built array resolves, what role does that array's very last entry always have? Could it ever end up being a user-role entry instead, given the exact termination rule you implemented in lecture 05 §4?
Checkpoint
Always assistant, never user. The loop's only exit path pushes an assistant turn (the one that turned out to contain zero tool_use blocks) immediately before checking whether to return — the push and the termination check are two adjacent steps in the same iteration, in that order, every time. There is no way for the loop to conclude with a user-role entry (a fresh request, or a pending set of tool results) sitting at the end, because a user-role entry only ever gets pushed when the loop is about to make another call to the model, not when it's about to stop. That guarantee is exactly what makes "read the last entry" a reliable way to find the subagent's answer, rather than a lucky guess that happens to work on this course's test scripts.
Stuck? See Snippet 1 in the Appendix.
5. Problem 7.1: Implement the task Branch
Concept
Put the last four sections together and the branch is already fully specified by things you know: build a self-contained starting point out of the model's own prompt text, recurse into agentLoop sharing the exact model and ask you were given, then read the answer back out of the array that recursive call just finished mutating. Nothing here calls a function you haven't met; nothing here needs a new type declared. It's a composition of six lectures' worth of pieces, aimed one layer inward at the loop itself.
Two conventions from lecture 05 carry over completely unchanged, rather than needing a new rule invented for this sixth kind of tool_use:
- The result still needs a
tool_use_id. Whatever text (or error) comes back from the subagent still has to be wrapped into an ordinarytool_resultblock, tagged with the id off thetasktool_useblock that requested it — the same join-key discipline from lecture 01 §2 and lecture 05 §3, applying identically here to a subagent's report as it does to any of the five original tools' results. - Failure still becomes
is_error: true, not a crash. If the recursive call throws — say the subagent's own model call fails — catch it exactly the way lecture 05's tool-execution walk catches a thrownrunToolerror: the caught message becomes the result'scontent,is_errorflips totrue, and the parent's loop keeps going instead of the whole conversation dying. A subagent that fails is just one more piece of information the parent model gets to read and react to — not a special case.
Why It Matters
Notice what didn't have to happen to ship this feature: no change to any of the five existing runTool cases, no change to preview or confirm, no new field on any existing type, no new test double invented beyond the ones lecture 05 already built. Six lectures of building model and ask as parameters rather than hardcoded globals, and building the is_error/content shape once, uniformly, for every tool — all of that paid for this feature in advance. That's the actual argument for keeping primitives narrow and composable the first time: a sixth capability you hadn't designed for yet still slots into the same three-way branch shape lecture 05 already taught you to write, without reopening anything that already worked.
Your Task
Spec. Inside agentLoop, recognize a tool_use block whose name is task, and handle it with its own path rather than sending it through the ordinary gated/ungated dispatch the other five tools use. For that block: build a brand-new messages array holding exactly one entry — a user turn whose content is the prompt string the model supplied as this call's input. Recurse: await a call to agentLoop itself, passing that new array along with the same ask and the same model you were already given as parameters. Once that call resolves, look at the last entry in the array you just built. If it's an assistant turn, pull the text out of its content blocks — there may be one, several, or (if the subagent's last turn happened to contain no text at all) none — and join whatever you find into a single string; if that string comes out empty, substitute a short placeholder noting the subagent didn't return any text. Wrap whatever you end up with — the joined text on success, or a caught error's message on failure — into a tool_result tagged with the task block's own id, following the identical is_error convention as every other branch, and push it into this turn's results the same way any other tool's outcome gets pushed.
Worked example. Picture the parent mid-turn: its own array currently ends on an assistant turn holding one tool_use block — name task, some id, and a prompt reading "count the .txt files in the fixtures directory and report the number." The branch builds a second, entirely separate array holding one entry: a user turn whose content is that literal sentence — none of the parent's own history rides along. That new array is handed to a fresh run of the loop, which might call list_files once, look at what comes back, and finish on a plain text turn reading "there are 3 .txt files." Back in the parent's branch, that array's last entry is now that assistant turn; its text is pulled out, and one tool_result — content "there are 3 .txt files," is_error false, tagged with the original task block's id — joins whatever else this parent turn produced, to be sent back in the same single results message every turn already uses.
Constraints (from tests/07_subagents.test.ts):
- (a) The
tool_resultreaching the parent hasis_error: false, atool_use_idmatching thetaskcall's own id, andcontentequal — exactly, not a prefix, not wrapped in extra text — to the subagent's final text. - (b) The subagent's very first call to the shared, scripted model sees an array of length exactly one; that entry has role
"user"and content equal to the literal prompt string. Nothing from the parent's own prior conversation appears anywhere in what that call was shown. - (c) A subagent that calls an ordinary, ungated tool completes that whole exchange — request, execution, result fed back, a further model call reacting to it — before the parent's own next model call happens at all. The shared model's call count reflects every one of those calls, in the order they actually happened.
- (d) A subagent's attempt at a gated tool consults the exact same
Askerthe parent itself was given. A scripted "n" through it means the command never runs (no side effect lands on disk), and the parent'staskresult still carries the subagent's own honest, non-error report about being declined — a decline inside a subagent is not, itself, a failure at the parent's level. - (e) The parent's own
messagesarray, once everything resolves, has the four-entry shapeuser → assistant(task tool_use) → user(tool_result) → assistant(final text)— thetaskbranch changes nothing about the parent's own turn-taking shape from lecture 05.
Hint: Everything the branch needs — the model, the ask, the block currently being processed — is already sitting inagentLoop's own parameters and the loop you're already inside. No new imports, no new module-level state. The extraction step ("the last entry's content blocks" → "one string") is a filter-then-join over an array shaped exactly like the one lecture 05's text-block printing already walks — same kind of array, just built into a joined string instead of handed toconsole.log.
Common mistake: Passing the parent's own messages array (or a copy of it) into the recursive call instead of a fresh, one-entry array. That produces something that isn't a subagent at all — it's the parent, continuing its own conversation with extra steps — and test (b)'s isolation check (call length exactly one, no trace of the parent's own text anywhere in it) catches this immediately. A related version of the same mistake: building the fresh array correctly, but then reading the answer back out of the parent's array instead of the fresh one the recursive call actually mutated — the recursive call never touches the parent's array, so there's nothing useful to find there.
Unlock questions:
- Predict the subagent's first model call precisely: given a
taskcall whose prompt reads"ping", what is the entire array passed tomodel(...)on that first recursive call — its length, each entry's role, each entry's content? Is there any way the parent's own earlier turns could leak into it, even by accident, given howagentLoopis built? - While the recursive
agentLoopcall is running, what state is the parent's own iteration of itswhileloop in — exited, or merely paused? What resumes it, and at what point? - Test (d) shows a subagent's gated
bashcall getting declined, and the parent's owntaskresult comes back withis_error: false, nottrue— a "no" one level down doesn't become a failure one level up. Why is that the right behavior, given what lecture 04 §6 already established about what a denial is, as opposed to what an error is? - Suppose the subagent's own recursive call throws partway through — its own model call fails outright, say. Walk through what the
taskbranch'stry/catchdoes with that, and what the parent model ends up seeing in the resultingtool_result'scontentandis_errorfields, using the exact convention lecture 05 §3 built for every other tool's thrown failure.
Then implement it: add this branch to your agentLoop, in index.ts.
Checkpoint
Run the suite:
./ok 07
Real output, captured from this exact repo, before the branch exists (the untouched skeleton, agentLoop still a bare throw new Error("YOUR CODE HERE")):
ok: running tests for problem 07
RUN v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano
❯ tests/07_subagents.test.ts (5 tests | 5 failed) 7ms
× returns the subagent's final text to the parent as a non-error tool_result 3ms
× shows the subagent's first call exactly one message: the prompt, none of the parent history 1ms
× lets the subagent list_files and finish its whole loop before the parent resumes 1ms
× declines a gated bash call in the subagent and executes nothing 0ms
× records assistant(task tool_use) → user(tool_result) → assistant(final text) 0ms
Test Files 1 failed (1)
Tests 5 failed (5)
Every failure is the same Error: YOUR CODE HERE, thrown from agentLoop's stub body — expected, since the whole function is still unwritten. Once your branch is in place, expected output flips to:
ok: running tests for problem 07
RUN v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano
Test Files 1 passed (1)
Tests 5 passed (5)
All five green: the parent-receives-the-answer case, the empty-context isolation case, the subagent-completes-its-own-loop-first case, the gate-fires-inside-a-subagent case, and the parent's-own-shape-is-unchanged case. That's Problem 7.1 done — the course's +1 bonus point, on top of the 10 already earned through lecture 06.
Stuck? See Snippet 1 in the Appendix.
The Live Run (Optional): Watching a Subagent Report Back
Concept
./ok 07 green is the graded bonus point, in full — five scripted tests, a fake model queue, no network call, no wall clock, no key required. What none of them show you is the thing lecture 06 §5 already put you through once: a real model, handed the real task schema, actually reaching for delegation on its own, and a real subagent doing real work the parent never touches directly. This is that same live-run tradition, aimed one layer inward — same key, same command, same "victory lap, not a gate" framing lecture 06 §5 opened with, except this time you're watching the parent's context stay exactly as small as one tool_result while an entire nested conversation happens underneath it.
Why It Matters
Section 2's checkpoint told you, in the abstract, that a subagent's first call sees exactly one message and nothing of the parent's prior history. ./ok 07's test (b) already proved that, cold, offline. What a scripted test can't show you is what that isolation actually looks like happening, in real time, in your own terminal: the log visibly splitting into two actors, the parent asking a real model to delegate, a subagent going and doing its own work down a level you never asked it to show you — and the parent's own next line of output built entirely out of a report it never independently verified. Watching that gap is the clearest way this course has left to make "context isolation" stop being a sentence in a tool schema and start being a thing you watched happen, live.
Your Task
If you don't have a key available right now: skip this entirely. ./ok 07 green plus §§1–5's unlock answers already earned the full +1 bonus point — 11/10, done. Come back whenever a key is available; nothing below changes that.
Step 1 — export a real key (the same one lecture 06 §5 used):
export ANTHROPIC_API_KEY=sk-ant-...
Step 2 — start your agent from the claude-code-nano/ directory:
npx tsx index.ts
Step 3 — ask for delegation directly, rather than hoping the model reaches for task on its own. Type something close to:
Use the task tool to delegate this: read fixtures/convert.js and report
what the program does and its exact output format. Then tell me what
the subagent reported.
Step 4 — watch. What follows is illustrative — a plausible shape, not a captured transcript (no live API call was made in writing this lecture; real model wording varies run to run):
nano> Use the task tool to delegate this: read fixtures/convert.js and report
what the program does and its exact output format. Then tell me what
the subagent reported.
● task({ prompt: "Read fixtures/convert.js and report what the program does and its exact output format." })
● read_file({ path: "fixtures/convert.js" })
The program reads a Celsius temperature from argv, converts it, and
prints a single floating-point number (no units, no label) to stdout.
The subagent reports: fixtures/convert.js takes one command-line
argument (a Celsius value), converts it, and prints the raw numeric
result as its entire output — no surrounding text.
Notice the two levels of ● logging: the outer task(...) call is the parent's own tool call; the indented read_file(...) underneath it belongs to the subagent's loop, not the parent's — the parent and subagent each keep their own separate messages array, exactly as Section 1 described. The parent's own final line is a summary of a report it received in one tool_result — it never called read_file itself, never saw fixtures/convert.js's contents directly. Everything the parent knows about that file arrived secondhand, through exactly the one channel §4 described: the mutated array a recursive agentLoop call left behind. That's context isolation, observed rather than asserted.
A note on gated tools. If your prompt instead pushes the subagent toward a gated tool — asking it to delegate a fix, say, rather than a read-only report — the [permission] preview and y/n prompt still land on your own terminal, same as ever. Nothing about being one level down changes where the question goes: it's the identical ask reference threaded through the recursive call (§3), so the same human answers it, no matter how deep the recursion runs — the same "same seam, same ask" argument this lecture made in prose, now something you can watch fire from inside a live subagent instead of a scripted test.
Step 5 — end the session. Type /exit.
Checkpoint
There's nothing to diff on disk here — this run is read-only start to finish, so there's no fixture to restore the way lecture 06 §5's Step 6 required. The only thing to confirm is what you watched: a task call in the log, a subagent's own tool activity nested beneath it, and a parent summary built from a report rather than from firsthand access. If you saw that shape, you've watched the whole lecture's argument happen in real time. If you skipped this section entirely, nothing is lost — the bonus point was already yours the moment ./ok 07 went green.
6. The Honest Limits: What Nano's Subagents Don't Do
Concept
Be precise about the size of what you just built, the same way lecture 06 §6 was precise about what main() doesn't do. Nano's task tool is the minimal true form of the idea — everything about it is a real, working instance of "an agent that delegates to another agent" — but real Claude Code's subagents go further in several concrete ways this course's scope deliberately excludes:
- Parallelism. Nano's subagent runs to completion, sequentially, before the parent resumes at all — the same one-
tool_use-at-a-time discipline lecture 05 §3 already committed to for its ordinary tools ("this course's loop processestool_useblocks sequentially within a turn... a production agent might parallelize independent reads"). Real Claude Code can dispatch several subagents at once and wait on all of them together, the same kind of change lecture 06's own retrospective sketched for parallel tool execution generally. - Their own instructions and tools. Nano's subagent inherits the exact same
SYSTEM_PROMPTand the exact sameTOOLSarray as the parent — it can even calltaskitself, recursing further, with no restriction. Real subagents are typically configured with their own, narrower system prompt and a scoped-down subset of tools appropriate to the delegated job, rather than inheriting everything wholesale. - Structured results. Nano's subagent reports back exactly one thing: the joined text of its last assistant turn, a single string. Real Claude Code's subagent results can be richer, structured objects — not just "whatever text came last."
Why It Matters
None of these are bugs in what you built — they're the same "engineering added around an already-correct core" story lecture 06 §6 told about tools, permissions, and model I/O. The core mechanism — delegate to a fresh instance of the same loop, isolate its context, read back its final answer — is identical between Nano's version and the real one. Everything in the list above is a refinement of how much gets configured per subagent and how many run at once, not a different architecture underneath. You built the actual trick; real Claude Code just spends more engineering deciding which system prompt, which tools, and how many subagents at a time.
Your Task
No code — one sentence. Pick one item from the list above and say, concretely, which part of the task branch you just wrote would need to change to support it (a new field passed through the tool's input? a change to how many recursive calls run at once? something else?).
Checkpoint
There's no graded answer key here, the same as lecture 06 §6's retrospective task — this is a comprehension check on your own understanding, not a ./ok-checked problem. If you can point at, say, "giving each subagent its own system prompt" and immediately say "the fresh array's construction would need a place to fold in extra instructions, the same way SYSTEM_PROMPT gets built once for the parent," you've understood the shape well enough to extend it. That's the point.
Stuck? See Snippet 2 in the Appendix.
7. Final Score: 11/10
Concept
Ten points across six required lectures, plus this one: 11 out of 10. That's not a typo and it's not a rounding gimmick — it's the curriculum's own bonus-point homage to a classic optional problem: skippable, worth real credit, and not required to call the course complete.
Two things remain undone by design, and they stay that way on purpose. NANO.md (a project-level system prompt, lecture 06's extra challenge 1) and /context (a token-accounting REPL command, extra challenge 2) are still sitting at zero points, with no test, no appendix, and — as of this lecture — still no answer key. Extra challenge 3, the one that used to describe exactly what you built today, is retired: this lecture is its answer key, in full, with a real test suite behind it. The other two are not getting the same treatment. They stay exactly as lecture 06 left them: real, working, self-contained extensions of the same six functions you now know cold — and the only two places left in this entire course where "done" is a judgment call you make yourself, with nothing to check your work against but whether it does what you meant it to do.
Why It Matters
Look back at what closing this lecture actually required: no new type, no new test infrastructure beyond what lecture 05 already built, no rewrite of anything that worked before today. A subagent tool — the thing lecture 06 flagged as needing you to "trace exactly how far \[async-ness] ripples" through the whole file — turned out to ripple through nothing, because model and ask were already parameters, is_error/content was already a uniform shape, and agentLoop already mutated its argument in place. That's the six-lecture argument this course has been making the whole time, closing on itself one last time: build the loop's seams narrow and honest, and the seventh feature you didn't know you'd need yet still slots in.
Your Task
No code. Reread lecture 06 §7's original sketch of extra challenge 3, now that you've built the real thing. Does anything in today's implementation surprise you against what that sketch predicted — anywhere it undersold the difficulty, or oversold it?
Checkpoint
There shouldn't be a large gap, and if there is one, it's most likely in the direction of "easier than advertised" — lecture 06's own framing called tracing the async ripple "most of the actual challenge," but the actual ripple turned out to be exactly one branch, inside a function that was already async, calling a function that was already awaitable, with parameters that were already sitting there waiting to be reused. That's not a flaw in lecture 06's warning — it was written before this lecture existed to walk you through it — it's a demonstration of the same thing this whole course has argued from lecture 01 onward: once the shape of a thing is genuinely understood, extending it stops looking like new difficulty and starts looking like composition.
No appendix snippet for this section — it's a retrospective, not a coding problem.
Key Takeaways
- An agent that can use tools can be a tool. The
taskbranch's executor isagentLoopitself, called recursively — not a new subsystem, the same function whose stub you filled in during lecture 05, aimed at a different array.
- Each subagent gets its own
messagesarray, not a different data structure. Ataskcall builds a completely separate array for the subagent, and that array is still exactly the flat, append-only log lecture 01 taught you to read. Nothing about any single array changed; only the fact that there can now be more than one, with the parent's array picking up the subagent's result through onetool_result.
- Context isolation is the point, not a limitation. A subagent that inherited the parent's whole history would just be the parent, talking to itself. Starting from exactly one self-contained message is what lets a subagent explore freely without bloating the parent's context — the same design real Claude Code's Task tool uses, and the reason the
taskschema's own description tells the model, in plain words, to put everything the subagent needs into the prompt.
- The branch lives in
agentLoop, notrunTool, because of the same seam argument lectures 04 and 05 already made twice.runToolwas kept deliberately model-blind and gate-blind; a subagent needs the injectedmodeland the sharedAskerthat onlyagentLoopalready carries. Recursing with those same two parameters is what keeps gates firing inside subagents and keeps every test intests/07_subagents.test.tsrunning offline, exactly like every lecture since 05.
agentLoopstill returnsvoid— the answer is read out of the mutated array, not returned. Lecture 05's "mutation is load-bearing" claim aboutmain()'s REPL turns out to be load-bearing here too: the recursive call's own return value carries nothing, so the fresh array you built before the call is the only place the subagent's answer can be found once it settles.
- The same three-way branch shape from lecture 05 covers a subagent's outcome with no new rule invented: a
tool_resulttagged with thetaskblock's own id,is_error: falseand the subagent's joined text on success,is_error: trueand a caught error's message on failure — identical in shape to every one of the five original tools' outcomes.
- Nano's subagent is the minimal true form of the idea, not an approximation of it. Real Claude Code adds parallelism, per-subagent system prompts and tool subsets, and structured results — engineering layered on top of the same core mechanism, never a different architecture underneath.
What we skipped
- Parallel subagents. Nano dispatches and awaits one
taskcall at a time, the same sequential discipline lecture 05 §3 already committed to for ordinary tool calls; real Claude Code can run several subagents concurrently. - Per-subagent configuration. Nano's subagent inherits the parent's exact
SYSTEM_PROMPTand fullTOOLSarray, includingtaskitself (recursion has no built-in depth limit here); real subagents are commonly scoped to their own instructions and a narrower tool set. - Structured subagent results. Nano's subagent reports back one joined string — the text of its last assistant turn. Real Claude Code's results can be richer than a single string.
NANO.mdand/context— lecture 06's other two extra challenges, still worth zero points, still with no test and no answer key. This lecture answers extra challenge 3; those two remain exactly as open as they were.- A depth or breadth limit on delegation. Nothing in this course's
taskbranch stops a subagent from callingtaskagain, arbitrarily deep, or bounds how many subagents one subagent's own turn can spawn — production systems typically bound this; this course's toy conversations never run long enough to need it.
Appendix: Reference Implementation
Check your work here after attempting Problem 7.1. Don't read ahead into the snippet before you've tried the branch yourself — the whole point of §§1–5 was to let you derive this from pieces you already had.
Snippet 1: The task branch, in context
Drawn character-for-character from solution/index.ts — the branch sits inside agentLoop's existing for (const block of response.content) walk, checked before the ordinary GATED/runTool dispatch:
if (block.name === "task") {
// SUBAGENTS. The agent spawns an agent. Two lessons live here:
//
// 1. RECURSION is the natural shape of delegation. A subagent is just
// another run of THIS loop — same injected `model`, same `ask` — so
// gates still fire inside it and tests stay fully offline. The
// conversation becomes a tree: each task tool_use is a child branch.
//
// 2. EMPTY-CONTEXT ISOLATION. The child starts from a fresh messages
// array holding ONE user message: the prompt. It sees none of the
// parent's history — that's the whole point of a subagent. Whatever
// it needs must be spelled out in the prompt (see the tool's schema).
try {
const freshMessages: Anthropic.MessageParam[] = [{ role: "user", content: (block.input as any).prompt }];
await agentLoop(freshMessages, ask, model); // recurse — same model + ask
// agentLoop mutates freshMessages in place; the array IS the return
// value. The subagent's "answer" is the text of its LAST assistant turn.
const last = freshMessages.at(-1);
const answer =
last?.role === "assistant" && Array.isArray(last.content)
? last.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("")
: "";
content = answer || "(subagent returned no text)";
} catch (err: any) {
content = String(err?.message ?? err);
is_error = true;
}
results.push({ type: "tool_result", tool_use_id: block.id, content, is_error });
continue;
}
This sits before the if (GATED.has(block.name) && ...) check from lecture 05 — a task call never consults GATED at all; the branch above returns (via continue) before that check would ever run for this block. Every other tool_use name falls through to lecture 05's existing dispatch, completely unchanged.
Snippet 1: The task branch, in context — unlock answers
- §1: Paused, not exited. The parent's
whileloop suspends at theawait agentLoop(freshMessages, ask, model)line and resumes only once that entire nested conversation has finished — the same suspend/resumeawaithas always meant in this course, now aimed at a call to the currently-running function itself. - §2: Exactly one entry —
[{ role: "user", content: "check whether utils.js still has the bug we found." }]— with no trace of the parent's five prior turns anywhere in it, becausefreshMessagesis built from scratch, holding only(block.input as any).prompt, never a copy or reference to the parent's ownmessages. - §3: A test scripting a gated denial inside a subagent (
tests/07_subagents.test.ts's test (d)) would fail if the branch built its own stand-inAskerinstead of passing along theaskparameter already in scope — the scripted"n"would never reach whatever the branch actually asks, and the gated command could execute unchecked, leaving the sentinel file present when the test expects it absent. - §4: Always assistant.
agentLoop's own termination check (lecture 05 §4) only returns immediately after pushing that turn as an assistant entry — there's no path back to the caller with a user-role entry last, which is exactly what makes readingfreshMessages.at(-1)a sound way to find the subagent's answer rather than a guess.
§5 (Problem 7.1) unlock answers:
model(...)'s first call receives an array of length 1:[{ role: "user", content: "ping" }]. There is no path for the parent's earlier turns to leak in, because the branch never reads from or copies the parent'smessages—freshMessagesis a brand-new array literal, containing nothing but the prompt string thetaskcall itself supplied.- Paused — the parent's
while (true)loop is suspended at the lineawait agentLoop(freshMessages, ask, model), exactly like every otherawaitin this file. It resumes on the next line only once that call's promise settles, which (by lecture 05 §4's termination rule) happens only after the entire subagent conversation has produced a turn with zerotool_useblocks. - Because a denial was never an error to begin with — lecture 04 §6 established that
confirmreturningfalseis "a completely ordinary, expected outcome," not a malfunction, and that the harness converts it into a normaltool_resultwithis_error: false. That's exactly as true one level down: the subagent's own gated call being declined is the gate working correctly inside the subagent, and the subagent's honest report of that ("I was not allowed to run that") is a perfectly successful subagent turn — nothing about the parent's owntaskresult should claim a failure occurred when nothing failed; the gate simply said no, and the subagent said so. - The
catchblock catches whatever the recursiveagentLoop(freshMessages, ask, model)call throws, converts it to a string viaString(err?.message ?? err)— the identical fallback lecture 05 §3 used for a thrownrunToolerror — setsis_errortotrue, and that string becomes thetool_result'scontent. The parent model sees a failed subagent exactly the way it would see any other failed tool call: real error text,is_error: true, and the freedom to react to it (retry with a different prompt, try a different approach, or give up) on its very next turn — never a crash that takes down the parent's own loop.
Snippet 2: Limits notes
There's no single correct answer to §6's task — like lecture 06 §6's retrospective question, it's a comprehension check, not a graded problem. A representative example, if you want something to compare your own reasoning against: supporting per-subagent tool scoping ("give this subagent only read_file and list_files, nothing that writes") would primarily change two things — the task tool's input_schema would need a new optional field (say, an array of allowed tool names) for the model to fill in, and the branch inside agentLoop would need to filter which tools get offered to the recursive call, most likely by threading an allowed-tools list down to wherever callModel's default tools: TOOLS gets decided for that particular call — which, notably, is the one function in this whole file (callModel) that today assumes a single fixed TOOLS array for every call, parent or subagent alike. That's a real, if modest, ripple — smaller than lecture 06 originally worried about for the feature you actually built today, but not zero.
End of lecture. Ten points across six required lectures, one bonus point here — 11 out of 10, and NANO.md / /context still waiting, exactly as open as lecture 06 left them, for whenever the training wheels come off for good.