Agent profiles, part four: the fan-out campaign model
How I brief long-running, multi-agent campaigns across Claude Code and Codex: the goal file, the run contract, what compaction actually does, and roles instead of model names.
RevisedReplaces the re-read-after-compaction rule with the current-state record, rewrites Rule Zero around asynchronous questions, and moves the failure catalogue to part five.
Part three covered how one shared document, the protocol I use to brief a multi-agent campaign, stays honest across the 41 repositories that consume it. This part is about the document itself: the model I use to run a coding agent unattended for hours, across dozens of tasks, without babysitting it or coming back to a mess.
The goal file is the unit of work, not the chat prompt
A long chat prompt cannot be relied on to preserve routing, authority, traps and corrections across a multi-hour campaign, because a compaction event throws most of it away and there is nothing to re-read. A Markdown file on disk survives that. So the durable unit of a campaign is a goal file, and the chat message that starts the run is a short pointer to it, not the brief itself.
Each repository driven this way gets a codex/ directory at its root, listed in .gitignore, holding one set of files per run: codex/goal-<date>-wave<N>.md, codex/launch-<date>-wave<N>.txt, and codex/report-<date>-wave<N>.md. The name is historical and stays whatever harness runs the wave, because a sync script mirroring these files between machines matches that exact directory name; rename it to something harness-neutral and the sync stops silently. Gitignoring the directory, not a filename pattern, keeps an unanticipated artefact type from leaking into history, and keeping artefacts in the repository means an agent given only the checkout can find the last three runs’ goals and reports with one ls.
The goal has to be self-contained, and that is a separate requirement from existing on disk. Authoring and execution are different reading surfaces: the person writing the goal reads whichever sections of the protocol apply and freezes the resulting execution contract into the goal, and the agents running the wave read that contract. They do not reload the whole sourcebook, its historical examples and three harness appendices they are not running on, every time a context window turns over. What the goal keeps instead is the source revision and the section references, so an agent that finds a genuine gap can retrieve the one section that fills it.
Corrections work by supersession. A correction or a new phase gets its own new goal file, never a silent rewrite of the instructions an earlier run already received. That matters because a goal file gets copied forward across runs, and a stale fact inside one is invisible: it reads exactly like a current one. One run’s goal said an interface change would ship together with two sibling items as a single revision, true when written. That line survived into its third consecutive draft, by which point both siblings had already shipped, eleven and twenty-one runs earlier, and nobody had queried the tracker item’s actual state, only its wording. The fix is a standing rule: query state, not body, before carrying a reference forward. One loop covers a whole goal and costs seconds against a run that costs hours.
What compaction actually does, and why “re-read the goal” was the wrong default
Every goal used to open with an instruction to re-read the file in full after compaction. That instruction was wrong, and what showed it was wrong was counting rather than arguing about it.
A scan of September’s Codex session files across three profiles: the personal profile held 1,178 of them, carrying 951 text-summary transitions across 904 distinct context windows once 47 inherited duplicates were excluded. The nonproxy profile, the only one in the fleet eligible for the experimental context-management mode, showed 41 native compactions and 64 fresh-context resets. Every reported context window in the entire scan was 258,400 tokens, and the first post-transition token count on the work profile had a median of 32,072.
The number that actually changed the rule is smaller than any of those. In one campaign, all ten observed native compactions had a goal read in the first four tool calls afterwards, several of them fetching overlapping ranges of the same file. Native compaction is the mechanism that is supposed to carry the state across. It carried it, and the agent went and re-read the goal anyway, because the goal told it to. Retention did not remove the recovery cost, because a standing instruction to re-read beats a context that did not need re-reading.
So the rule is now to identify what actually happened before recovering from it, and there are four answers:
| What happened | What the run does next |
|---|---|
| Native encrypted compaction | Use what was retained, check the state record is still fresh, fetch only the sections that are missing or have changed. |
| Text-summary compaction | Use the summary and the state record together, and resolve what the summary omits with targeted reads. |
| Experimental fresh-context reset | Read the state record explicitly. Nothing from the previous window can be assumed to have survived. |
| Unknown | Read the state record, recover the constraints the next action depends on, and record the uncertainty instead of guessing. |
Unknown being one of the four is the part that took evidence. The tells are unreliable: a compacted marker in a transcript does not say which mechanism produced it, a visible prose summary does not prove the text-summary path because retained text coexists with native encrypted compaction, and a banner announcing that another model produced a summary proves nothing at all. Labelling a transition by the wording of its handoff is how a run ends up confidently recovering the wrong way, so the honest label is a permitted answer.
What replaces the whole-goal re-read is one root-owned file beside the goal, codex/state-<run-id>.md. It holds current facts rather than a diary: run identity and state revision, the goal’s path and content revision, the observed profile and model and the recovery mechanism with its evidence, active lane ownership and pending returns, the tested SHA or dirty-state identity and the CI run it maps to, every operation labelled planned, attempted, running, completed, failed or unverified, and the next action together with the facts needing a live readback before it happens. Obsolete detail gets compacted out of it rather than appended to. A new revision is written after a consequential decision, an ownership handoff, an accepted lane return, a completed gate or a commit readback, and never once per tool call.
Two things about that file are easy to get wrong. A timestamp is not freshness: what proves it current is the goal revision, the last recorded boundary, the pending operation ids and whether a newer retained result contradicts it. And it is not a second tracker. Per-task outcomes and decisions still belong on the tracker; the state record points at them and holds only what changes the next decision.
One boundary matters more than the rest, and it is stated as an exclusion because otherwise it gets inferred the wrong way. None of this applies to a fresh session. A /new, or opening a session and typing a prompt, is outside the recovery contract entirely: an agent starting cold must not go looking for an earlier campaign’s state file and adopt it. These rules are for continuing one session across a context transition, and nothing else.
Four mechanisms, four different recoveries, and “unknown” as a permitted answer. The strip at the bottom is the exclusion: none of this applies to a session started cold.
The run contract names the harness once, and every field earns its place
Every goal opens with an explicit run contract: run mode, human availability, whether the root may ask asynchronous questions, the current layer of work, which external systems the run may write to, which harness it launches on, the observed runtime profile, the root’s role and the model and effort that resolves to, the topology and why it was chosen, the path to the run’s current-state record, and where the final report goes. The topology rationale must name the independent bottleneck that justifies fanning out at all, because Google Research evaluated 180 agent-system configurations and found 39 to 70 percent degradation on sequential planning tasks. Splitting work is not free just because you have the agent count for it; real sequential dependencies get worse from being cut into parallel lanes, so decomposition needs an argument, not just a count.
The report destination line sits in the contract itself, not only in the report section further down, and that placement is deliberate: the contract is what an agent reads first and what it keeps in its active recovery state, so a rule stated there is one it is still holding when it has every reason to just stop. The default destination is a file at an exact path, not a chat message. A tracker existing does not change that: per-item outcomes belong on the tracker, but a file report is required as well, because a chat message does not survive the transcript being discarded and does not sit beside the goal for the next run to find.
Three run modes, and front-loaded is usually the right one
The obvious two modes both waste the human. Daytime sounds efficient and instead produces a long day of interruptions over decisions that could have been settled in one sitting beforehand. Unattended is honest about not interrupting anyone, but it forces the goal to guess at every fork the human was sitting right there to answer an hour earlier.
Front-loaded is the third mode and it is usually the correct default. The human is awake and reachable, and precisely because of that, every fork was put to them before the goal was written. Once launched, the run behaves like an unattended one in the sense that finishing never depends on an answer, and questions that could not be resolved in advance get batched into the final report instead of silently defaulted into a fallback queue.
Three rules make it work. Extract every fork before writing the goal, and write the answers in as frozen decisions with a date and a name attached: a fork answered out loud in chat and never written into the goal did not get answered. State what a lane does when it hits a decision the goal does not cover: take the goal’s default, or the narrowest reversible option inside its own frozen contract, implement it, and record the choice; anything touching product scope, a shared contract, ownership or authority goes back to the root instead, since reversibility never grants permission to widen scope. And the final report requires a dedicated questions section that cannot be merged or dropped for feeling unimportant, the batch the human was spared from answering piecemeal.
Front-loaded means the human is awake and reachable, and precisely because of that every fork was put to them before the goal was written. The run then behaves like an unattended one: no answer is required mid-run. Root async questions remain allowed unless the goal disables them; unresolved questions are batched into the final report rather than defaulted silently into a fallback queue.
It is worth naming as its own mode because the two obvious modes both waste the human. Daytime invites a long day of interruptions over decisions that could all have been taken in one sitting beforehand. Unattended is honest about not interrupting but forces the goal to guess at forks the human was sitting right there to answer.
Three rules make it work:
- Extract the forks before writing the goal, and write the answers in as frozen decisions with the date and the person. A fork answered in chat and not written into the goal did not get answered.
- State what a lane does with an uncovered decision: take the goal’s default; if there is none, take the narrowest reversible option within its frozen contract and granted authority for a routine implementation choice, implement it, and record the choice. A worker returns an uncovered product, shared-contract, ownership or authority decision to the root; it does not change those boundaries. The root applies a frozen default or resolves the decision within its existing authority. If new authority or a material human choice is required, park that lane, record the question for the final report and continue independent work. Routine choices are not blockers; reversibility alone never grants permission to widen scope.
- Require a dedicated questions section in the final report, separate from everything else. That section is the entire point of the mode: it is the batch. Say it must not be merged into another section and must not be omitted because nothing felt important enough.
The goal never changes after launch, the state record changes throughout, and the report is the last unit of work rather than an answer to a question asked the next morning.
Roles, not model names
The document never says “use Claude Opus” or “use gpt-5.6-sol” in its body. It talks about roles: RETRIEVAL, MAPPING, GATE, EXECUTION, JUDGMENT+EXECUTION, REVIEW, DESIGN+INTEGRATION, SECURITY. A separate harness profile resolves each role into a concrete model and effort for whichever CLI is running the wave. That indirection is what lets one document drive both Claude Code and Codex, which route work differently, without keeping two copies that could drift apart: a complete table for Codex, and a thin appendix for Claude Code that defers to rules its own sessions already load automatically.
One question decides a lane’s role. Can you state the acceptance check now? If yes, route it cheap: RETRIEVAL or MAPPING for read-only work, EXECUTION for something fully specified with a frozen seam. If no, it needs DESIGN+INTEGRATION first, to freeze the seam before anyone touches code. JUDGMENT+EXECUTION sits between the two, for work whose check is known but whose implementation carries real judgement or wider blast radius.
There is a cross-check too, because an unclear acceptance check is not the only reason to spend more. Something wrong-and-cheap-to-detect can go cheap, caught immediately at little cost. Something wrong-and-silently-propagating, a frozen seam, a data model, cardinality or personal data, goes expensive regardless of how well-specified it looks on paper, because that kind of mistake ships and surfaces lanes later.
Classify the actual work in every lane, not its title, phase or repository count. A lane called “implementation” is not automatically judgement-heavy; a review is not automatically a security review. For each judgement/design lane, name the decision still open, why the goal and code do not already answer it, and why a cheaper route cannot safely finish. If none remains, use the matching execution, mapping, gate or ordinary review route. Do not commission same-model worker groups merely for convenience or copy routes from an older wave; matching routes are valid when each lane’s actual work justifies them. Check root, lane table, individual briefs, rescue rules and launch message for agreement against the selected harness revision.
Separate decision work from implementation only at a useful, independently verifiable handoff. An accepted packet supplies the relevant contract, repository conventions, owned files, inputs, interfaces, error/lifecycle cases and discriminating checks. Do not require an extra design agent, rewrite an adequate packet or repeatedly move a tiny remaining fix between models merely to use a cheaper route. A root may perform a bounded authorised correction itself after taking ownership; the harness profile defines its capability boundary, and independent review remains independent.
One question routes most lanes. The cross-check is what overrides it: something that fails silently and propagates goes expensive however well specified it looks.
The lane brief, and the two definitions worth writing down
A complete lane brief carries a lot of fields: role, route, context scope, delegation authority, objective, prerequisites, owned files, forbidden files, frozen decisions, acceptance check, validation, retry budget, stop rule, escalation evidence. Repeating all of that across a dozen near-identical lanes makes a goal long and hard to re-read after compaction, and the fields that go missing are always the boring ones: retry budget, stop rule, escalation evidence.
The fix is to hoist everything genuinely identical across lanes into one shared block before the lane list, with the sentence “these apply to all lanes below and are not repeated.” Each lane entry then carries only what varies, and a lane needing a different value overrides it explicitly, which makes the exception visible instead of burying it in boilerplate nobody reads twice.
What a lane returns is specified as tightly as what it does, and for the same reason. A lane returns its status, the scope it changed or inspected, the validation result with the exact tested identity, what it proved, what it did not prove, any decision it could not cover, and a recommended next action. It returns the relevant excerpt of its evidence and leaves the bulk in an evidence artefact. It does not return its exploration transcript or its full logs, because the root pays context for every byte of that and then has to read past it to find the one line that mattered. The root checks the consequential claims against the evidence and inspects further only where it needs to; it does not re-run mechanical work that already succeeded just to see it succeed again.
Two definitions belong in that block rather than being assumed, because both get misread the same way with nobody around to notice. A retry is not a re-run: it means acting on new evidence, a different error, a corrected assumption, a file not previously read. Re-issuing the same failing command unchanged is a loop, and it will consume a whole unattended night. Escalation evidence is not “it didn’t work”: it is the file and line, the command and its verbatim output, the exact name the lane wanted and why, and what it would do given an answer, because nobody is awake to ask the follow-up question.
One file, one owner
The baseline rule is one file, one owner. Shared and generated integration files belong to the root or a named wiring pass, never to two lanes at once, which handles most contention.
It does not handle the single append-only function every lane must add an entry to, and that case needs solving before fan-out rather than during it. Part five covers it, along with the rest of the failures that only show up after every lane has reported success.
Running with nobody awake
An unattended goal carries what the document calls Rule Zero, and the wording of it has changed since I first wrote it down. It used to say there is no human available, so asking a question is equivalent to stopping. It now says no human answer is required, so never wait. The difference is not pedantry: it is the difference between a rule about questions and a rule about waiting, and only the second one is actually true.
The root may send a genuinely non-blocking question at any point, including at three in the morning, because an incidental reply might unblock a lane and sending one costs nothing. What it may not do is turn that into a dependency. Having sent the question it immediately applies the existing default or its delegated authority; if the next action genuinely needs an answer it does not have, it records the blocker, parks that lane and moves on to independent work or the fallback queue. It never polls for a reply, never keeps a turn alive waiting for one, and never postpones the final report because a question is outstanding. Children still never ask anyone anything: an uncovered decision goes back to the root, which owns recording it and routing any answer onwards.
The rule that costs the most to learn is about what an answer is. Delivery acknowledgement is not an answer. A preselected option is not an answer. An empty response is not an answer, and neither is elapsed time. Only an actual human reply supplies new approval, so silence never widens scope or authority. On Codex this runs through functions.request_user_input_async, whose acknowledgement means only that the question was emitted; the reply, if there is one, turns up separately as a user message. Where the harness exposes no such tool, the run takes exactly the same no-answer path rather than substituting a synchronous prompt that would block it.
Beyond that, the root works through an ordered fallback queue of independently safe work agreed in advance, rather than inventing scope on the spot.
That still leaves a correctly parked lane as a failed lane. So where the root meets a stated capability floor, it carries delegated authority to repair small defects in its own goal, settle well-evidenced architectural forks, implement small bounded prerequisites, and amend a seam it froze earlier in the run, exhausting that authority before parking anything. The gate on it is confidence: act on a fork or seam amendment only at roughly 70 percent confidence or better, since a wrong decision taken at low confidence costs more to unwind than the parked lane costs to resume. Everything decided under the grant goes into the final report with its evidence and rejected alternatives; the reader re-grades that confidence, and the root’s own grading is an input, never a verdict.
The same document, two different harnesses
The protocol itself is public, rendered into 41 repositories’ trackers and published on this site from the same canonical copy, and it stays harness-neutral for a reason: Claude Code and Codex differ in kind, not just naming, and a lane written against one harness’s mechanics fails on the other in ways its own acceptance check will not catch.
Two of those differences each cost a run something before they got written down. Passing a name to a background subagent in Claude Code turns a one-shot worker into a persistent teammate that goes idle awaiting messages instead of completing, so no completion event fires and its deliverable never reaches the dispatcher, only a ping saying it is idle. This was isolated with a controlled comparison, one dispatch named and the other not, and it has no equivalent on Codex. And a subagent inherits the parent’s permission mode and cannot opt out, while a soft block only clears on the user’s own message naming the action, which a subagent’s transcript never contains: a lane can be stuck on something the root was allowed to do, with nothing able to unblock it. The fix is not to retry the lane, which reads as talking past the block, but to keep privileged actions, deploys, secret-store writes, destructive git, on the root.
These are not naming differences. A lane written against Appendix A’s mechanics and run on Claude Code fails in ways its own acceptance check will not catch.
-
There is no
fork_turns, and the middle option does not exist. Context scope is binary:subagent_type: "fork"inherits the whole conversation, anything else starts fresh. There is no “last N turns”. A lane needing partial context gets a fresh agent and the relevant facts written into its brief — which is what §3 prefers anyway. A fork also always runs on the parent’s model; amodeloverride on a fork is ignored.A fork is therefore the most expensive spawn shape available: it copies the entire parent conversation into a second context that then re-sends all of it on every one of its own turns, at the parent’s model, with no way to route it cheaper. Reach for it only when the lane genuinely needs the conversation itself; a self-contained brief is both cheaper and the default §3 asks for.
-
effortis not a parameter on theAgenttool. It is settable only in an agent definition’s frontmatter or inWorkflow’sagent()opts. A lane brief that specifies an effort through a plain dispatch is a silent no-op — the lane runs at the session’s effort and nothing reports the discrepancy. Where a lane genuinely needs a different effort, it needs an agent definition or aWorkflow, not a sentence in the brief. -
There is no ten-thread pool.
Workflowcaps concurrent agents atmin(16, CPUs − 2)and queues the excess rather than refusing it, so saturation is invisible; theAgenttool has no documented cap at all. §4’s reserve is therefore a ratio here and not a count, and “we did not hit the cap” is not evidence the fan-out was sized correctly. -
Delegation depth is enforced differently at each surface.
Workflowforbids nesting outright — aworkflow()call inside a child throws. Agent-spawned subagents can spawn further, soDelegation: forbiddenin a lane brief is a real instruction there, not a restatement of a platform limit. -
Naming a background agent swallows its deliverable. Passing
name:promotes a one-shot subagent into a persistent addressable teammate; teammates go idle awaiting messages instead of completing, so no completion event fires and the final message never reaches the dispatcher — only an idle notification. Re-asking by message produces another idle ping. This has no Codex analogue, and it directly breaks §5’s “Return exactly:” contract and §10’s report chain. Dispatch unnamed, or synchronously, or hand off through a file at an absolute path.subagent-dispatch.mdowns this, including the A/B experiment that isolated it. -
A lane cannot clear a permission block the root could have cleared. Subagents inherit the parent’s permission mode and cannot opt out, while a soft block clears only on the user’s own message naming the action — and a subagent’s transcript contains no user message. A dispatch brief is explicitly refused as consent. So a lane can be blocked on work the root was allowed to do, with nothing able to unblock it, and re-dispatching is treated as bad faith. Lanes do read-only investigation, code edits, tests and inventory sweeps; SSH, deploys, tenant or cloud mutations, secret-store writes and destructive git stay on the root. If a lane returns blocked, the root runs that step itself.
Those are the mechanics. Part five is the other half: the failures that survive all of it, where every lane passed its own acceptance check, the gate came back green, and the run still shipped something wrong.