My fully autonomous implementation system splits work between a planner agent and a fleet of implementer sub-agents that start with zero context. For months, roughly one in three implementer runs solved the wrong problem, even though the planner's prose instructions looked fine to me. The fix was not a better prompt. It was a task spec contract: a small, structured schema the planner must emit and the implementer must echo back before touching code. Retries dropped from 31% to 7%, and I got a bonus: the same spec became the input for my verifier agent. Here is the schema, the war story that forced it, and five lessons about agent-to-agent handoffs.
Quick background. The system I have been building for about a year runs Claude Code (2.x as of September 2026) in a loop: a planner reads the repo and the backlog, breaks a goal into tasks, and hands each task to a fresh implementer sub-agent. The implementer edits, runs tests, and reports back. A separate verifier agent reviews the diff. Humans (me) only see the summary.
The planner and the implementers do not share a context window. That is deliberate. Fresh context keeps each implementer cheap and focused, and it lets me run four or five of them in parallel. But it also means every task handoff is a cold start. Whatever the planner forgets to write down simply does not exist for the implementer.
Early on, the handoff was a paragraph of natural language. Something like:
Add retry logic to the webhook sender so transient 5xx errors don't drop events. Keep it simple.
Reads fine, right? Here is what actually happened across a sample of 120 handoffs I logged in spring 2026:
| Outcome | Count | % |
|---|---|---|
| ✅ Done, verifier approved on first pass | 68 | 57% |
| ⚠️ Done, but scope crept or wrong file touched | 37 | 31% |
| ❌ Gave up or produced nothing useful | 15 | 12% |
The 31% row was the expensive one. Those tasks looked done. The implementer wrote a confident summary, tests passed, and only the verifier (or worse, me, the next morning) noticed that "retry logic" had been implemented as a brand new generic retry utility with its own config file, exponential backoff, jitter, a circuit breaker, and 400 lines of tests. For a webhook sender that already had a retry helper two directories over.
The specific incident that made me stop and redesign the handoff:
The planner emitted a task: "Fix the flaky date parsing in the export job." The repo had two export jobs. One was a legacy CSV exporter that nobody had touched in a year. The other was the active JSON exporter that had the actual flaky test. The implementer grepped for "export", found the legacy one first, "fixed" its date parsing by rewriting it to a different library, updated its tests, and reported success. The verifier approved because the diff was internally consistent. The actual flaky test kept flaking for three more days.
Nobody in that chain did anything wrong given what they knew. The planner knew which exporter it meant. It just never said so, because to the planner it was obvious. That is the core failure mode of cold-start handoffs: the sender's obvious is the receiver's unknown.
I stopped treating the handoff as a message and started treating it as an interface. If the planner and implementer were two services, I would never let them talk in free text. I would give them a schema. So I did.
Every task the planner emits must be a single fenced block that validates against this shape:
task_id: T-2026-0914-03
goal: >
Make the JSON export job's date parsing deterministic so
`test_export_dates_across_dst` stops flaking.
why: >
The flaky test blocks CI ~2x/day; the root cause is naive
datetime handling around DST transitions.
scope:
allowed_paths:
- src/export/json_exporter.py
- tests/export/test_json_exporter.py
forbidden_paths:
- src/export/csv_exporter.py # legacy, do NOT touch
- src/shared/** # shared helpers need a separate task
context:
- "There is an existing tz helper at src/export/tz.py; use it, do not write a new one."
- "The test currently fails ~30% of runs on the 2026-03-08 fixture."
acceptance:
- cmd: "pytest tests/export/test_json_exporter.py -x --count=20"
expect: "all 20 runs pass"
- cmd: "git diff --stat"
expect: "only the two allowed_paths appear"
forbidden_moves:
- "Do not add new dependencies."
- "Do not change the public signature of export_json()."
- "Do not create a new utility module."
done_signal: >
Reply with the exact acceptance command outputs, then the
diff stat. If any acceptance check cannot pass, stop and
report which one and why. Do not work around it.
budget:
max_turns: 40
max_minutes: 25
A few of these fields deserve explanation, because the obvious ones (goal, acceptance) are not where the value came from.
scope.forbidden_paths mattered more than allowed_paths. Allowed paths tell the implementer where to look. Forbidden paths tell it where not to fix things, which is exactly the information that was missing in the date parsing incident. I now make the planner write at least one forbidden path for every task, even if it has to think hard to find one. The act of choosing what to fence off forces the planner to surface the ambiguity it was carrying in its head.
context is for facts the implementer cannot discover cheaply. "There is already a tz helper" is a 10-second fact for the planner (it just read the repo) and a 15-minute discovery for a cold implementer, if it finds it at all. This field is where I put the things the planner "obviously knows."
forbidden_moves is for behaviors, not files. "Do not create a new utility module" kills the 400-line-retry-framework failure mode outright. My current top three forbidden moves, by how often they appear:
done_signal defines what the implementer's final message must contain. Before this, implementers reported success in prose. Now they must paste the acceptance command outputs verbatim. This one change made the verifier's job dramatically easier, because it could diff the claimed output against a re-run.
The schema alone got me from 31% to about 18% scope failures. The second half of the fix was a mandatory echo-back: the implementer's first action, before reading a single file, is to restate the task in its own words in a fixed format.
## Task echo
- I will change: src/export/json_exporter.py, tests/export/test_json_exporter.py
- I will NOT change: src/export/csv_exporter.py, anything under src/shared/
- I am done when: 20 consecutive pytest runs pass AND diff stat shows only 2 files
- Things I must not do: add deps, change export_json() signature, create new modules
- Open questions: none
If the echo does not match the spec, a tiny check script rejects it and the implementer gets one retry. If Open questions is non-empty, the task is bounced back to the planner instead of proceeding. This felt like ceremony when I added it. It turned out to be the single highest-leverage step, because roughly half the remaining scope failures were the implementer misreading a correct spec, and the echo catches those for the cost of one short turn.
Here is the flow now:
flowchart LR
P[Planner] -->|task spec YAML| V{Schema valid?}
V -- no --> P
V -- yes --> I[Implementer<br/>fresh context]
I -->|task echo| E{Echo matches spec?}
E -- no, 1 retry --> I
E -- open questions --> P
E -- yes --> W[Edit + run acceptance cmds]
W -->|done_signal with raw outputs| R[Verifier]
R -->|re-runs acceptance cmds| M{Match?}
M -- yes --> Done[Merge]
M -- no --> P
The schema check is deliberately dumb. It is about 60 lines of Python and does not use an LLM. That matters: I want the gate to be deterministic and free.
REQUIRED = {"task_id", "goal", "scope", "acceptance", "forbidden_moves", "done_signal", "budget"}
def validate(spec: dict) -> list[str]:
errors = []
missing = REQUIRED - spec.keys()
if missing:
errors.append(f"missing fields: {sorted(missing)}")
scope = spec.get("scope", {})
if not scope.get("allowed_paths"):
errors.append("scope.allowed_paths must be non-empty")
if not scope.get("forbidden_paths"):
errors.append("scope.forbidden_paths must be non-empty (yes, really)")
for i, check in enumerate(spec.get("acceptance", [])):
if "cmd" not in check or "expect" not in check:
errors.append(f"acceptance[{i}] needs both cmd and expect")
if len(spec.get("forbidden_moves", [])) < 1:
errors.append("at least one forbidden_move required")
return errors
Yes, the forbidden_paths must be non-empty rule is annoying for trivial tasks. I kept it anyway. A planner that cannot name one thing the implementer should not touch has not thought about the task hard enough.
Same 120-task sample size, measured in August 2026 after the contract had been in place for six weeks:
| Outcome | Before | After |
|---|---|---|
| ✅ Approved first pass | 57% | 81% |
| ⚠️ Scope creep / wrong target | 31% | 7% |
| ❌ Gave up / nothing useful | 12% | 12% |
Two things I want to be honest about. First, the "gave up" row did not move. The contract fixes misunderstanding, not capability. When a task is genuinely too hard for a single implementer, a better spec does not save it. Second, planner cost went up about 20% per task, because writing a spec takes more tokens than writing a paragraph. That is paid back many times over by the retries I no longer run, but it is not free.
1. Treat every cold-start handoff like an API boundary, because it is one. You would never let two microservices exchange free-text and hope. Two agents with separate context windows are exactly that situation. Give them a schema, validate it, and reject bad payloads before they cause work.
2. The sender's "obvious" is the receiver's "unknown." Design the schema to extract it. The most valuable fields (forbidden_paths, context, forbidden_moves) are all just structured ways of forcing the planner to write down what it was silently assuming. Free-form prompts let the planner skip that. A required field does not.
3. Negative space beats positive space. Telling an agent what to do is table stakes. Telling it what not to do is where the leverage is. An implementer with a clear goal and no fences will happily solve the goal in the most expansive way it can imagine. Fences make "the simplest thing that works" the path of least resistance.
4. Make the receiver echo before it acts. One short turn of restating the task catches misreads that would otherwise cost twenty turns of wrong work. It also gives you a clean place to surface open questions instead of letting the agent guess. This is the cheapest reliability upgrade I have ever added to the system.
5. Define the shape of "done," not just the definition. "Tests pass" is a definition. "Paste the raw output of these two commands" is a shape. Shapes can be checked mechanically by the next agent in the chain. Definitions require judgment, and judgment is where drift creeps in.
The spec has become the backbone of more than just the planner-implementer handoff. The verifier now reads the same YAML and re-runs acceptance itself, so a task can only be marked done if two independent agents get the same outputs. I am working on two extensions:
budget field is currently just a kill switch. I want the planner to use its own past I also want to try the echo-back pattern on the human side. If I have to write a task echo for my own tickets, I suspect I will discover I am just as bad at stating scope as my planner used to be.
If you are running any kind of multi-agent setup with Claude Code, or even just handing tasks to a single fresh session, try the contract before you try a better prompt. Start with four fields: goal, forbidden_paths, acceptance with real commands, and a done_signal that demands raw output. Add the echo-back. Measure your retry rate before and after. I would genuinely like to hear whether your numbers look like mine.
If this was useful, follow me here on Dev.to 🚀. I write one post like this every week or so about building a fully autonomous implementation system, the stuff that broke, and what I changed. And if you have a handoff schema of your own, drop it in the comments. I am collecting them.