A captured claude --print session against a Node + TS demo app with three services that each grew their own ad-hoc logger. One prompt asked for a shared logger plus three parallel subagent migrations; the article shows the actual sequence, the actual subagent prompts, and the judgment call that broke “strict equivalence” intentionally on one service.
The setup
Three services in src/, each with a slightly different logger shape:
booking/service.tshaslog(level, message, extra)writing toconsole.logwith a[booking]prefix.notifications/service.tshaslogLine(kind, msg, data)mixingconsole.logandconsole.error, withINFO/ERRlevels.reporting/service.tshasemit(caller, severity, line)writing every line tostderr, tagged with the calling method name. A comment in the file explicitly says “should be stdout but we never got around to it.”
Tests are green (9/9) on main after scenario #29 fixed the timezone bug. The capture script wraps claude --print --output-format stream-json --verbose so every tool call lands in events.jsonl, not just the final summary.
The prompt
We have three services in src/ (booking, notifications, reporting). Each has
its own ad-hoc logger function with a slightly different shape. Design a
single shared logger in src/shared/logger.ts that all three services can use,
then migrate the three services to it in parallel using subagents, one
subagent per service. Keep the existing log output roughly equivalent
(scope, level, message, structured data). Run the tests when each subagent
finishes.
“Roughly equivalent” was the load-bearing word. Strict equivalence on the reporting service would have meant routing info logs to stderr, which the comment in the original code calls a bug. The prompt left room for the lead to make that call.
The lead agent’s exploration (before any edits)
The events.jsonl shows the lead’s first eight tool calls were all reads. In order:
Bash(find ... -name "*.ts" | head -50): get the file layoutBash(ls answerqa-demo-app/): top-level filesRead(src/booking/service.ts)Read(src/notifications/service.ts)Read(src/reporting/service.ts)Read(src/booking/service.test.ts)Read(src/shared/time.ts): existing shared module shapeRead(src/index.ts): entry point
Eight tool calls before any code is written. That investment is the article. Without it the lead would not know the three services use three different log shapes, and the subagent prompts that come later would be vague.
The shared logger is designed and tested before fan-out
Tool call #14 writes src/shared/logger.ts. The actual factory:
export function createLogger(options: LoggerOptions): Logger {
if (!options.scope || options.scope.trim() === '') {
throw new Error('Logger scope must be a non-empty string')
}
const writeOut = options.out ?? ((line) => process.stdout.write(line + '\n'))
const writeErr = options.err ?? ((line) => process.stderr.write(line + '\n'))
const now = options.now ?? (() => new Date())
function emit(level: LogLevel, message: string, data?: Record<string, unknown>): void {
const ts = now().toISOString()
const dataPart = data === undefined ? '' : ` ${JSON.stringify(data)}`
const line = `${ts} [${options.scope}] ${level.toUpperCase()} ${message}${dataPart}`
if (level === 'error') writeErr(line)
else writeOut(line)
}
return {
info: (m, d) => emit('info', m, d),
warn: (m, d) => emit('warn', m, d),
error: (m, d) => emit('error', m, d),
child: (sub) => createLogger({ ...options, scope: `${options.scope}/${sub}` }),
}
}
Note three deliberate choices that paid off later: out/err/now are injectable so tests do not mock process.stdout; the empty-scope guard catches the most common factory misuse early; child returns a new logger with a slash-joined scope rather than mutating state.
Tool call #16 writes src/shared/logger.test.ts with five tests: level routing, the [scope] format, JSON serialization of structured data, child scope joining, and the empty-scope error. Tool call #17 runs npm test; 14 tests pass (9 existing + 5 new). The shared logger is verified before any service is migrated.
Sequencing is the design pattern. The irreversible decision (the API of the shared module) lands sequentially, alone. Fan out only the mechanical migrations that come after.
The fan-out: three Agent calls in a single assistant turn
Tool call #19 is three Agent tool calls dispatched in a single assistant message (same message.id in the events.jsonl). The lead built three subagent prompts and submitted them as one batch; Claude Code runs them in parallel.
Each prompt is highly specific. The booking subagent’s prompt begins:
Migrate `src/booking/service.ts` in /.../answerqa-demo-app to use the new
shared logger at `src/shared/logger.ts` (already written, do not modify it).
## Context
This project has three services, each with its own ad-hoc `log()` function
with a slightly different shape. We're consolidating onto one shared logger.
Two other subagents are migrating the other two services in parallel, only
touch the booking service files.
## The shared logger API (from src/shared/logger.ts)
```ts
import { createLogger } from '../shared/logger.ts'
const logger = createLogger({ scope: 'booking' })
logger.info('message', { id: 'abc' }) // stdout
logger.warn('message', { ... }) // stdout
logger.error('message', { ... }) // stderr
What to do
- Open
src/booking/service.ts. Note the existing ad-hoclog()function and its three call sites…
The reporting subagent's prompt is the most interesting because the lead made a structural call:
The reporting service is unique among the three: its current emit(caller, severity, line) function tags every log with the calling method name
(reporting/monthlyReport, reporting/formatForCity). Use the shared
logger’s child(subScope) to preserve that nested scope. Also note: the
current logger writes everything (even info) to stderr, that’s a quirk noted
as “should be stdout” in the existing comment, and we’re switching to the
canonical routing (info/warn → stdout, error → stderr) per the shared
logger. The user accepts “roughly equivalent” output.
The lead saw the comment in the original code, decided to honor the canonical routing, and put that decision in the subagent's prompt so the subagent does not have to re-derive it. That is the design pattern: lead resolves judgment calls, subagents execute mechanical work.
## What each subagent's first turn looked like
The events show every subagent did the same first move: it `Read` the service file and `Read` the shared logger, even though the prompt embedded the API. Subagents do not trust an embedded API spec; they verify against source. Useful to know if you are tempted to skip the API description in the prompt.
After verification, each subagent did its own `Edit` calls (multiple per file because the migration touches several call sites), then ran `npm test 2>&1 | tail -30` to confirm green. Each subagent's final message back to the lead was a short diff plus the test summary; under 250 words, as the prompt requested.
## The final state
Test Files 3 passed (3) Tests 14 passed (14) Duration 230ms
Three services migrated. Five new logger tests. No tests deleted, no tests weakened. One real judgment call (reporting's stderr quirk) handled in the lead, not in a subagent.
## Footguns
**Subagents work because the parent did the design first, not in parallel.** If the prompt had been "design and migrate in parallel", three subagents would have each picked a different logger API and the parent would have spent the synthesis turn reconciling three incompatible designs. The lead's eight-read exploration plus the shared-logger-tested-before-fan-out sequence is what made the parallelism cheap. Why this matters: when the design is the bottleneck, fan-out does not buy speed; it buys conflict. Reach for subagents on mechanical work that follows a settled design, not on the design itself.
**Each subagent prompt must be self-contained.** The lead's three subagent prompts each enumerate call sites, paste the API, restate the constraints. There is no shared chat state between subagents and the parent; the only channel is the prompt string passed via the Agent tool. Why this matters: a prompt that says "you know what we are doing, just migrate booking" gives the subagent nothing to verify against, and the subagent will Read 5 files trying to figure out what you mean. The lead spent the tokens to write three 250-word prompts; that was the right trade.
**Subagents do not message each other, only the parent.** Each subagent's output goes back to the lead, not to its peers. The "two other subagents are migrating X and Y in parallel, only touch your service" sentence in each prompt is the only context one subagent has about the others' work. If subagents need to coordinate (touch the same file, agree on a name), they cannot; the parent has to mediate by serializing them or by partitioning the work cleanly first. Why this matters: pick a partition where each subagent owns a disjoint slice of the file system. If two subagents would write to the same file, run them sequentially.
**Subagents read source instead of trusting embedded API specs.** Every subagent in this run did `Read(src/shared/logger.ts)` as its second tool call, despite the prompt embedding the API surface. That is correct, defensive behavior; it also means a 250-word API spec in your prompt does not save the subagent its initial Read. Why this matters: do not delete the API spec from the prompt thinking the subagent will skip the Read; keep the spec because the subagent uses it to know which file to Read.
**Stream-json output is the difference between knowing and guessing.** Without `--output-format stream-json --verbose` the capture script writes a five-line summary, not the tool-call sequence. The fan-out, the subagent prompts, and the per-subagent Reads are invisible. Why this matters: if you want to write an article like this one, or to debug "why did Claude pick that approach", upgrade the capture before the run, not after. Re-running to get richer data costs another full session.
**The Agent tool itself does not need an allow rule, but pre-approving subagent names does change behavior.** Per the tools reference, `Agent` is permission-required No, so it works in `--print` without an explicit `Agent` entry in the allow list. The case where you do reach for `Agent(SubagentName)` is when you want to constrain Claude to specific subagents (deny rules), or when you run with `--permission-mode dontAsk` and need every tool name pre-approved. Why this matters: do not assume "subagents are not running, so I must need an Agent allow rule" without checking your permission mode. Run `claude --debug` once to see whether Claude tried to invoke Agent and was blocked, or whether the prompt simply did not call for it.
## When NOT to fan out
- **The work is one file or one service.** Subagents have a fixed per-call setup cost (the prompt, the parent waiting, the synthesis turn). One file does not earn it back; just edit it.
- **Subagents would touch the same file.** Three subagents writing to `src/shared/utils.ts` will produce three concurrent Edit calls and one of them will lose. Partition the work so each subagent owns a disjoint slice.
- **The design is the bottleneck.** A new feature that needs three architectural decisions before any code lands does not benefit from parallelism; the lead has to make those decisions sequentially. Fan out only the mechanical execution after the design is settled and tested.
- **You need cross-subagent coordination.** Subagents cannot message each other. If subagent A's output changes the input to subagent B, run them sequentially or fold them into a single subagent.
- **You cannot describe the work in 250 words per subagent.** If the prompt is hand-wavy, the subagent will guess, and three subagents guessing in parallel produces three incompatible outputs. The cost of writing a precise prompt is paid by the lead; the cost of an imprecise prompt is paid by the synthesis turn at the end.