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?