11 minDEXagentsaiarchitecture

Nine stages, nine amnesiacs

Dex builds a project unattended by making sure no agent ever remembers anything. State travels through the filesystem instead — which works beautifully for artifacts and catastrophically for decisions.

Every long agent session dies the same way, and it never looks like dying. It looks like agreement.

Somewhere past the hundred-thousandth token the model stops pushing back. It re-reads a file it read forty minutes ago. It forgets the constraint you gave it in turn four — not dramatically, just quietly, the way you forget a name at a party. It marks a task complete with total confidence and a test suite it never ran. The prose is still fluent, the tone is still crisp, the bullet points still line up. That is exactly the problem. Fluency is the last thing to go.

The industry’s answer to this is a bigger context window, which is the right answer if you believe the failure mode is capacity. I don’t. A long session is not a bucket filling up; it is a room getting messier, and past a certain point the model spends more of itself navigating the mess than doing the work.

Dex is built on the opposite bet: never let a session get long enough to rot. It runs a project as a sequence of discrete stages — clarify, constitution, gap analysis, specify, plan, tasks, implement, verify, learnings — and every single stage is a brand new agent with an empty context window. Nine stages, nine amnesiacs. None of them has ever met the others.

AGENTS — ONE FRESH CONTEXT WINDOW EACH no context flows sideways specify agent #4 plan agent #5 tasks agent #6 implement agent #7 verify agent #8 FILESYSTEM — READ AT THE START OF A STAGE, WRITTEN AT THE END spec.md tasks.md learnings.md git refs

You can say that in one sentence. Three genuinely hard problems live inside it. Here they are, in the order they hurt.

Problem 1: if nobody remembers, what carries?

The filesystem carries. That is the whole trick, and it is not mine — it is Geoffrey Huntley’s Ralph Wiggum loop, which is four words of bash and one genuinely good idea. State does not travel through the context window. It travels through disk. Each stage reads what it needs, works inside the sharp part of a fresh window, and writes its results back.

For artifacts this works beautifully. spec.md is a file. tasks.md is a file. The code is files. An amnesiac who can read is not meaningfully worse than one who remembers, as long as everything worth remembering was written down.

For decisions it works terribly, and it took me several hundred dollars to find out why.

Dex’s loop has a stage called gap analysis whose only job is to look at the clarified plan, look at what exists on disk, and decide what happens next. The first version did the obvious thing: re-read the whole plan every cycle, reason about it, and say what it wanted in plain English. The orchestrator then 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 away from the thing that was asked for. Nothing failed. Every individual output was reasonable. The drift lived entirely in the gaps between them.

The fix was to stop asking. A stage called manifest_extraction runs exactly once, early, and produces feature-manifest.json under a JSON schema — every feature, in order, each with its user stories and acceptance criteria frozen at extraction time. From then on, choosing the next feature is not an LLM call at all:

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. Gap analysis still runs — but now it answers a closed question with a closed set of answers:

Decision Meaning
NEXT_FEATURE Start the next manifest entry
RESUME_FEATURE Continue an in-flight feature
RESUME_AT_STEP Re-enter a partial cycle at a named stage
REPLAN_FEATURE The plan is wrong — regenerate it
GAPS_COMPLETE Nothing left; terminate the loop

Five variants, schema-constrained, no parsing. The model still does the judgement — is this plan salvageable or does it need regenerating? is a real question and I want a real intelligence answering it. It just no longer gets to express the answer in a sentence I have to interpret.

Lesson: between two agents, prose is a wire protocol. Prose is a catastrophically bad wire protocol. Use the model’s judgement, then make it commit to one of n values.

Problem 2: the Stop button is a promise

Dex’s Stop button is documented as pause, not kill. You are meant to be able to step away, look at what it built, stop for the day.

For a long time that promise held only if you happened to click Stop at a cycle boundary. Click it anywhere else and here is what you got: specify had finished, plan hadn’t started, and on resume the loop counted the aborted cycle as complete, began a fresh one from gap analysis, and left the spec directory that specify had just written orphaned on disk. Never planned against. Never implemented. Just sitting there — a folder you paid four dollars for and could never use.

The cause is banal and probably lives in your codebase too. Cycles were the unit of the loop, so the abort check lived at cycle boundaries. Stages were an implementation detail of a cycle. That was true right up until stages appeared in the UI as a row of things with their own progress and their own names, at which point the user’s mental model became “stages” and the engine’s model was still “cycles”, and every bug in that gap was invisible to me and infuriating to anyone using it.

The fix is the RESUME_AT_STEP row in that table above: persist lastCompletedStage, and let a resume re-enter the middle of a cycle against the same spec directory rather than restarting the cycle from the top.

That is unglamorous work. It shipped as a spec of its own, and it is the single change I would defend hardest, because a pause button that loses work is worse than having no pause button at all. No pause button is a limitation. A pause button that eats a stage is a trap — it teaches people that the safe-looking control is the dangerous one, and once someone has learned that about your tool they stop trusting the parts that actually work.

Problem 3: undo, for eight hours you weren’t watching

Autonomy has an obvious consequence that took me embarrassingly long to design for: if it worked, you weren’t there. The interesting question is therefore never “what is it doing right now”. It is “what did it do, and how do I take some of it back”.

So every completed stage commits. Not as a nicety — as the actual storage model. Each commit message is two lines: one for the human, one for the machine.

dex: plan completed [cycle:2] [feature:product-catalogue]
[checkpoint:plan:2]

Which means the run is fully reconstructable from git, by anyone, without the app. The Timeline tab is a branching DAG of exactly those commits — one column per branch, one node per stage — and a left-click on any node does the right git operation for that node: check out the branch if you clicked a tip, fork a throwaway selected-<ts> branch if you clicked something mid-history. Dirty working tree? A dialog offers Save — which is a normal commit on your current branch — or Discard. There is no fifth concept to learn.

Two more gestures round it out. Remove this version deletes a branch Dex owns, and if that branch is the only place some work exists, it names those stages in plain English first (“Cycle 2 — Plan”) rather than showing you a list of SHAs and wishing you luck. Make this the new main promotes any version into the primary line, and when the merge conflicts — it does — a one-shot agent resolves them file by file, restricted to Read and Edit, capped on iterations and dollars, with your project’s own verify command as the thing it has to satisfy before the resolution counts.

The user-facing goal is that a person who has never run git rebase can navigate eight hours of autonomous work with single clicks. The engineering goal is subtler and more useful: git is not an implementation detail here, it is the source of truth. state.json is a cache of what the refs already say. I know this because I once built it the other way around, and the resulting bug was so good it gets its own post.

I should also admit the shape of the road here. I shipped considerably more timeline verbs than the three above — parallel variant fan-out into git worktrees, a promote verb, an un-promote verb, a record mode — and then deleted almost all of it. That is also its own post, and a happier one than it sounds.

The takeaways, minus the product

Strip Dex away and four things survive that I would apply to anything that lets an agent run unattended:

  1. Context isolation beats context size. Design for a fresh brain, not a bigger one. The fresh brain is cheaper, more predictable, and gets better for free every time the model does.
  2. Prose is lossy between agents. 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 to put a schema.
  3. State that outlives a process belongs on disk — and if your users already have git, you already have versioned, branchable, shareable state with a twenty-year track record. Don’t invent a second one.
  4. Autonomy without a rewind button is not a feature, it is an incident. Build the undo before you build the thing it undoes. If you do it in the other order you will discover that undo was an architecture decision, not a feature, and you already made it wrong.

Dex is a desktop app — Electron, React, the Claude Agent SDK, no database, all state in files and refs — and it is on GitHub. It is early in the way that matters: it works, it has built real features unattended, and I still find something wrong with it most weeks.

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?