DEV Community

yureki_lab
yureki_lab

Posted on

How I Built a Task Spec Contract Between My Planner and Implementer Agents

TL;DR

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.

The Problem

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 war story that broke me

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.

How I Solved It

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.

The task spec schema

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Do not create a new module/file when an existing one can be extended.
  2. Do not add a dependency.
  3. Do not "clean up" code adjacent to the task.

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 echo-back step

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
Enter fullscreen mode Exit fullscreen mode

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 validation script

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
Enter fullscreen mode Exit fullscreen mode

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.

Results

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.

Lessons Learned

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.

What's Next

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:

  • Spec diffs for retries. When a task bounces back, the planner currently rewrites the whole spec. I want it to emit a diff against the previous spec so I can see what it learned from the failure, which should feed my self-improvement loop.
  • Budget-aware decomposition. The budget field is currently just a kill switch. I want the planner to use its own past budget overruns as a signal that a task should have been split in two.

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.

Wrap-up

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.

Top comments (4)

Collapse
 
reidmarlow profile image
Reid Marlow

The echo-back step catches what prompt tweaks miss. In our multi-turn agent runs, the failure mode was almost never a refusal to follow instructions. It was the worker interpreting a verb like "refactor" as license to tidy up adjacent call sites.

We ended up enforcing a mechanical diff check against git status right before the tool exit. If files outside the explicit allowed list were touched, the runtime reverted them immediately and prompted the agent with the discarded paths before the verifier ever ran. Forcing that boundary at the process level saved a lot of wasted test runs.

Collapse
 
raju_dandigam profile image
Raju Dandigam

The echo-back is the strongest part because it converts interpretation into a checkable precondition before side effects. I’d bind the task-spec hash to the echo and final evidence, then have the verifier reject any changed spec unless it receives an explicit spec diff and new approval; otherwise a retry can silently loosen forbidden paths. Are you measuring planner-spec defects separately from implementer misreads now that the contract exposes both?

Collapse
 
mthburnsbarberweb profile image
mthburnsbarber-web

"Negative space beats positive space" — this should be the first principle of any multi-agent handoff guide. The forbidden_paths/forbidden_moves fields being more valuable than allowed_paths is counterintuitive but makes total sense. A clear goal with no constraints leaves the implementer's imagination as the scope boundary, which is exactly how you get a 400-line retry framework when you asked for a one-liner.

The echo-back step is the one I'd have skipped as unnecessary ceremony. The data says otherwise — catching misreads before 20 turns of wrong work for the cost of one short turn is about as high-leverage as it gets.

The date parsing incident is the perfect case study: planner knew which exporter, implementer didn't, verifier saw a consistent diff and approved. Nobody failed. The interface failed. Treating agent boundaries like API boundaries is the mental model shift that makes this click.

Collapse
 
mihai_leanzero profile image
Mihai Perdum

The forbidden_paths field is the sharpest idea here, but I'm curious how it actually gets enforced once the implementer is running. The validator checks that the spec HAS a forbidden_paths list, and the acceptance step re-runs git diff --stat against an expect string - but that's still the implementer producing its own diff and reporting it in done_signal. A confidently wrong implementer could echo the spec perfectly, then edit csv_exporter.py anyway and just describe the output as if it matched. Does the verifier actually parse the diff stat and check the file list against scope programmatically, or is it comparing text against the expect string the same way the implementer would? That distinction is basically the whole ballgame for whether forbidden_paths is a real guarantee or just a much better prompt.