Structured handoffs, and what the model is still allowed to decide
When one agent hands work to the next, prose is the wire protocol — and a catastrophically bad one. The manifest that made feature selection cost zero tokens, and the line between what a schema pins down and what genuinely needs judgement.
Dex runs a project as a sequence of stages, each one a fresh agent that has never met the others. Everything they share travels through the filesystem.
For artifacts that works beautifully. spec.md is a file, tasks.md is a
file, code is files; an amnesiac who can read is not meaningfully worse than one
who remembers. For decisions it works terribly, and the reason is that the
moment a decision leaves one agent and enters another, something has to carry it
— and the default carrier is English.
Between two agents, prose is a wire protocol. Nobody designs it as one. Everybody ships one.
What drift actually looks like
Dex has a stage called gap analysis whose only job is to look at the plan, look at what exists on disk, and decide what happens next. The first version did the obvious thing: re-read the whole plan each cycle, reason about it, and say what it wanted in plain English. The orchestrator parsed that English with a regex.
You already know how this ends. Payments becomes Payment processing becomes
Checkout & payments. Feature four gets picked before feature three because the
model found it more interesting this morning. A description subtly rewrites
itself each cycle until the thing being built has drifted a full feature from the
thing that was asked for.
The part worth dwelling on: nothing failed. Every individual output was reasonable, defensible, and would have passed review in isolation. The drift lived entirely in the gaps between them, which is precisely why it took so long to see. There is no error to catch. There is only a slow rotation, one paraphrase per cycle, and each rotation is smaller than your threshold for noticing.
The fix: stop asking
The repair was not a better parser or a firmer prompt. It was noticing that “which feature is next” is not a question that needs intelligence at all.
A stage called manifest_extraction runs exactly once, early in a run, and
produces .dex/feature-manifest.json under a schema:
export const MANIFEST_SCHEMA = {
type: "object",
properties: {
features: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "number", description: "Feature number from the priority table (1, 2, 3...)" },
title: { type: "string", description: "Feature name from the priority table" },
description: {
type: "string",
description:
"Rich description including user stories, acceptance criteria, relevant data model entities, and scope constraints",
},
},
required: ["id", "title", "description"],
additionalProperties: false,
},
},
},
required: ["features"],
additionalProperties: false,
} as const;
Every feature, in order, each with its stories and criteria frozen at extraction time. From then on, choosing what to build next is not an LLM call:
const next = manifest.features.find((f) => f.status === "pending");
Zero tokens. Zero dollars. Zero drift. The same feature, in the same order, on every cycle and every resume, forever — including across a crash, a Stop, and a reopen three days later.
That last property is the one I underrated. A deterministic selector is not just cheaper than an agent; it is reproducible, which means a bug in cycle seven can be investigated by replaying cycles one through six and getting byte-identical choices. You cannot debug a system whose control flow re-rolls dice every time you look at it.
The line: what the model still decides
Here is the part I got wrong in the first draft of this architecture, and the part I most want to be precise about now.
Gap analysis still runs, and it still uses a real model, because “is this half-finished plan salvageable, or does it need regenerating?” is a genuine judgement call that I want intelligence answering. But the schema it answers into has exactly two values:
export const GAP_ANALYSIS_SCHEMA = {
type: "object",
properties: {
decision: { type: "string", enum: ["RESUME_FEATURE", "REPLAN_FEATURE"] },
reason: { type: "string" },
},
required: ["decision", "reason"],
additionalProperties: false,
} as const;
The loop as a whole acts on five decisions. The other three never touch a model:
| Decision | Decided by | Because |
|---|---|---|
RESUME_FEATURE |
the model | requires judging whether existing work is still coherent |
REPLAN_FEATURE |
the model | requires judging whether a plan is salvageable |
NEXT_FEATURE |
code | the manifest says which feature is first-pending |
RESUME_AT_STEP |
code | lastCompletedStage is a recorded fact |
GAPS_COMPLETE |
code | the manifest is either exhausted or it is not |
// (NEXT_FEATURE, RESUME_AT_STEP, GAPS_COMPLETE) are constructed deterministically
type LlmDecision = Extract<GapAnalysisDecision, { type: "RESUME_FEATURE" | "REPLAN_FEATURE" }>;
The rule I would extract: ask the model only the questions that would still be hard if a careful human had all the same files open. Everything else is bookkeeping wearing a question mark, and bookkeeping delegated to a model is a random number generator you are paying for.
The reason field is worth a note too. It is required, and nothing branches on
it — it exists purely so a human reading the run log can see why the model
chose to replan. Prose is fine as a payload. It is only dangerous as a
control signal.
The same shape, twice more
Once you have the pattern, the two remaining agent-to-orchestrator boundaries want it as well.
Verification returns a verdict, not an essay. Crucially it separates the three things a human conflates when asked “did it work?”:
export const VERIFY_SCHEMA = {
type: "object",
properties: {
passed: { type: "boolean", description: "true if ALL acceptance criteria pass and build/tests succeed" },
buildSucceeded: { type: "boolean", description: "true if the project compiles without errors" },
testsSucceeded: { type: "boolean", description: "true if all tests pass (or no tests exist)" },
failures: { /* criterion, description, severity: blocking | minor */ },
summary: { type: "string" },
},
// …
} as const;
The blocking / minor split on each failure is what makes an unattended loop
possible at all. Without it, every imperfection is either fatal — and the loop
stalls on a cosmetic misalignment — or ignorable, and the loop marches on over a
broken feature. With it, the orchestrator can route: blocking failures trigger a
fix cycle, minor ones are recorded and the feature still completes.
Learnings are the channel Ralph calls self-improvement, and they are the one place a run is allowed to influence its own future:
category: {
type: "string",
enum: ["build", "testing", "api", "architecture", "tooling", "workaround"],
},
Six categories, one-line insight, brief context. Those accumulate into
learnings.md, which later stages read. The categories are not decoration — an
untyped pile of “things I noticed” grows monotonically and becomes unreadable
around cycle five, and then every subsequent stage pays to read it and gets
nothing back.
Note also what the learnings prompt forbids: “Return structured insights — do NOT modify any files directly.” A stage whose job is to reflect on the work is the last stage that should be editing it.
What this costs
A frozen manifest cannot notice it was wrong. That is the direct price of
determinism. If feature three turns out, at cycle three, to have been a bad idea
— or to have been made redundant by how feature two landed — the manifest does
not know and the selector does not care. REPLAN_FEATURE exists as the escape
hatch, and it is coarse: it regenerates the plan for a feature, not the
feature list. Re-extracting the manifest mid-run is not something Dex does, and
every so often it should.
Schemas constrain what can be said, including things worth saying. The
two-value gap analysis cannot express “resume, but the third acceptance criterion
is now unreachable and someone should look at it”. The model may well have
noticed. There is nowhere to put it except reason, which nothing acts on. Every
enum you add to the safe side of a boundary is expressiveness deliberately thrown
away, and occasionally you throw away something you needed.
And the manifest is only as good as one extraction. The whole run inherits the quality of a single agent invocation early on, at the point where the least is known about the project. It is the highest-leverage LLM call in the system and it happens when context is thinnest.
The general lesson
Let the model judge. Don’t let it narrate.
The boundary where a decision leaves one agent and enters another is the single highest-value place in an agent system to put a schema — higher than the prompt, higher than the tools. Inside one agent, prose is the medium and it is a good one. Crossing between agents, prose is a serialisation format with no version, no validator, and a parser written by someone who was guessing.
Next: your test suite bills by the token — how do you test a system whose slowest, least deterministic and most expensive component is the one you did not write?