<!-- Generated by `pnpm generate:llms`. Do not edit by hand. -->

# I write the spec before I open the coding model

*2026-08-15*

> I plan with the best model I have, then hand a fast one a short spec with no code in it. Three studies say the same thing I feel every week — the agent isn't the expensive part, the re-explaining is.

By the fourth prompt I'm not building anything. I'm repeating myself.

The same validation rule. The same repository. The same five controller methods — to an agent that invented a sixth one while I was typing.

That repeating is the bill. The code is cheap.

So I stopped opening the coding model first. I write a short spec, using the best model I have that day. Then a fast model builds it.

- the best model I have writes the spec
- the spec holds the flow and the rules this repo already follows — never code
- a fast coding model builds it, preferably one that still reasons
- the file goes to the next person, so the standard isn't stuck in my chat history

## Planning gets the best model I have

This is the one thing I won't downgrade. Bugfix, new feature, a folder that doesn't exist yet — same rule. Whatever the strongest reasoning model I have access to that day is the one that writes `spec.md`.

It's the right place to spend the money because planning is judgment, not typing. What's in scope. What the service layer already covers. Where the flow branches. What "done" means when the ticket is one paragraph long.

A cheaper model will still hand me a document. It just hands me one with all the ambiguity left in it, and I pay for that ambiguity later, during the build, when it's expensive.

The planning session ends when the spec reads clean. I don't let that model write the feature.

## The coding model only has to be fast and obedient

Building is volume. Lots of files, lots of small edits, lots of test runs. I want something quick enough to stay in the loop with me that still thinks for a second before it edits — a reasoning coding model.

When the budget is tight, a non-reasoning model like Composer is a genuinely good deal. It's fast and it follows a tight spec closely. It falls apart on a vague one. That's the whole trade, and the spec is what makes the cheap side of it safe.

Either way, neither one opens before `spec.md` exists.

## The spec holds the flow, never the code

I don't put code in a spec. The moment I do, the agent copies it word for word, and now I'm maintaining two versions of the same class that drift apart by Thursday.

What goes in instead is the business path — a flowchart or plain pseudocode — plus the patterns this project already chose. Repository, service layer, one validation step per operation, the formatter that's already running. I name the pattern. I don't rebuild it in Markdown.

```text
# Cancel an order

## Flow
customer taps cancel on an order
  → order already shipped? reject, with a reason
  → not their order? reject, forbidden
  → cancel through the order service, refund through the existing gateway call
  → notify the warehouse, return the updated order

## Keep
- one validation step per operation
- queries live in the repository, not the controller
- money changes go through the service, never a controller
- the formatter and lint rules this repo already runs, unchanged

## Done
- a shipped order cannot be cancelled
- another customer's order returns forbidden
- a partial refund does not double-refund
- the response doesn't leak the internal warehouse status
```

Greenfield or a ten-year-old app, same shape. On a new project I write the patterns I want invented once. On an existing one I point at what's already in the tree — for a Laravel API, that's the [five stops a request already travels](/blog/every-rest-convention-already-has-a-file-in-laravel). Either way the file describes the shape. The code is the output.

A clear PRD and a real UAT make the spec sharper, and they aren't a requirement. A thin ticket still gets a spec. A thin ticket is exactly when I need one.

## What this looks like on a Laravel ticket

Here's the whole loop, on a normal ticket: *customers should be able to cancel an order before it ships.*

1. I open the planning model first and point it at the parts it needs — `routes/api.php`, the `Order` model, the `OrderService` that already exists. I ask for a spec, not code. Flow, rules, done list.
2. I read it. This is the part that pays. Reading the flow out loud is where I notice nobody said what happens to an order the warehouse already picked but hasn't shipped, and that a partly refunded order would hit the gateway twice. Fixing that in the spec is one sentence. Fixing it after the build is a pull request, a review, and a second round of testing.
3. Once it reads clean I open the fast coding model and say implement `spec.md`. It writes the `CancelOrderRequest`, the policy check, the service method, the button on the order page, and the Pest tests for the two rejections. I review a diff instead of a conversation.
4. When it gets something wrong — and it does — I fix the spec and run implement again. I don't patch it in the chat. Patching the chat means the fix lives in a thread that closes tonight. Patching the file means the next ticket, and the next person, get it too.

A conversation also guesses early and sticks. That's my sixth controller method — decided in prompt one, then four more prompts arguing with a decision I never made.

That's where the prompt count drops. I'm not explaining the repository pattern every session. I'm not listing which controller methods exist. And two engineers on the same team now ship the same shape, because the standard is a file in the repo instead of whatever each of us happened to type.

## Twenty tickets, and nine that buy nothing

Someone measured the part I was only feeling. A [preregistered benchmark of 24 coding tasks](https://arxiv.org/abs/2608.01347) ran the same fixes twice — once with the scope, the acceptance criteria and a stop rule written down, once with just the wish: *something in here is producing wrong results, improve it.*

Leave the scope vague and the model thinks 44% harder to land the same fix. It also lands it less often — 83% of the time, against a near-perfect record when the scope was written down.

Forty-four percent is nothing on one ticket. Line up a month of them, say twenty, and it stops being nothing.

```svg
<svg viewBox="0 0 640 264" role="img" aria-labelledby="spec-month-title spec-month-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="spec-month-title">What vague scope costs in thinking</title>
  <desc id="spec-month-desc">Spec first does the work. No spec does the same work plus 44% more re-explaining.</desc>
  <defs>
    <clipPath id="spec-month-a">
      <rect x="40" y="40" width="72" height="200" rx="8"/>
    </clipPath>
    <clipPath id="spec-month-b">
      <rect x="360" y="40" width="72" height="200" rx="8"/>
    </clipPath>
  </defs>
  <line x1="320" y1="8" x2="320" y2="256" stroke="var(--rule)" stroke-width="1"/>
  <text x="76" y="22" text-anchor="middle" font-family="var(--font-sans)" font-size="16" font-weight="500" fill="var(--ink)">No spec</text>
  <text x="396" y="22" text-anchor="middle" font-family="var(--font-sans)" font-size="16" font-weight="500" fill="var(--ink)">Spec first</text>
  <rect x="360" y="40" width="72" height="200" rx="8" fill="var(--rule)"/>
  <g class="diagram-mark">
    <g clip-path="url(#spec-month-a)">
      <rect x="40" y="40" width="72" height="62" fill="var(--ink-muted)"/>
    </g>
    <text x="124" y="71" dominant-baseline="central" font-family="var(--font-sans)" font-size="16" fill="var(--ink)">re-explaining</text>
    <text x="308" y="71" text-anchor="end" dominant-baseline="central" font-family="var(--font-sans)" font-size="16" fill="var(--ink-muted)">+44%</text>
  </g>
  <g class="diagram-mark">
    <g clip-path="url(#spec-month-a)">
      <rect x="40" y="102" width="72" height="138" fill="var(--ink)"/>
    </g>
    <text x="124" y="171" dominant-baseline="central" font-family="var(--font-sans)" font-size="16" fill="var(--ink)">the work</text>
  </g>
  <g class="diagram-mark">
    <g clip-path="url(#spec-month-b)">
      <rect x="360" y="102" width="72" height="138" fill="var(--ink)"/>
    </g>
    <text x="444" y="171" dominant-baseline="central" font-family="var(--font-sans)" font-size="16" fill="var(--ink)">the work</text>
  </g>
  <rect x="40" y="40" width="72" height="200" rx="8" fill="none" stroke="var(--rule)" stroke-width="1"/>
  <rect x="360" y="40" width="72" height="200" rx="8" fill="none" stroke="var(--rule)" stroke-width="1"/>
</svg>
```

Same twenty tickets, same models, same people. Nine tickets' worth of spend that bought no feature and no test — just an agent working out decisions that had already been made, and getting them wrong more often while it did.

The honest limit: those were small tasks, at most four files, and the only thing that changed was the prompt. Nobody has measured a real month, so the nine is one ratio stretched across twenty, not my invoice. I use it as a direction.

## The model gets lost in the chat

These papers are why the spec is a file, not a thread. Same models. What they changed is how the work was handed over.

```svg
<svg viewBox="0 0 720 500" role="img" aria-labelledby="lost-illust-title lost-illust-desc" xmlns="http://www.w3.org/2000/svg">
  <title id="lost-illust-title">The model gets lost in the chat</title>
  <desc id="lost-illust-desc">Left: dump the whole brief in one message and the answer lands. Center: every model they tested gets a bit worse and more than twice as shaky once the same brief is dripped over chat. Right: one fact per turn, an early guess, and it never recovers.</desc>
  <defs>
    <marker id="lost-arr" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
      <path d="M1 1l5 3-5 3" fill="none" stroke="var(--ink-muted)" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
    </marker>
  </defs>
  <g class="diagram-mark">
    <rect x="8" y="8" width="196" height="484" rx="12" fill="var(--flow-terminal)"/>
    <text x="106" y="36" text-anchor="middle" font-family="var(--font-sans)" font-size="16" font-weight="500" fill="var(--flow-terminal-fg)">All at once</text>
    <rect x="24" y="56" width="164" height="64" rx="8" fill="var(--ground)"/>
    <text x="36" y="80" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">Build X.</text>
    <text x="36" y="100" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">I need A, B, and C.</text>
    <rect x="24" y="136" width="120" height="72" rx="8" fill="var(--ground)"/>
    <rect x="36" y="152" width="88" height="8" rx="4" fill="var(--ink)"/>
    <rect x="36" y="168" width="72" height="8" rx="4" fill="var(--ink)"/>
    <rect x="36" y="184" width="56" height="8" rx="4" fill="var(--ink-muted)"/>
    <circle cx="168" cy="172" r="16" fill="var(--flow-decision)"/>
    <path d="M161 172l5 5 11-12" fill="none" stroke="var(--flow-decision-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </g>
  <g class="diagram-mark">
    <ellipse cx="300" cy="150" rx="56" ry="62" fill="var(--flow-terminal)"/>
    <ellipse cx="430" cy="300" rx="62" ry="68" fill="var(--flow-io)"/>
    <text x="300" y="82" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--flow-terminal-fg)">all at once</text>
    <text x="430" y="222" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--flow-io-fg)">over chat</text>
    <line x1="236" y1="40" x2="236" y2="400" stroke="var(--rule)" stroke-width="1"/>
    <line x1="236" y1="400" x2="508" y2="400" stroke="var(--rule)" stroke-width="1"/>
    <text x="228" y="220" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--ink-muted)" transform="rotate(-90 228 220)">How good</text>
    <text x="372" y="424" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--ink-muted)">How often it flips</text>
    <line x1="284" y1="148" x2="404" y2="276" stroke="var(--ink-muted)" stroke-width="1.25" marker-end="url(#lost-arr)"/>
    <line x1="270" y1="168" x2="388" y2="296" stroke="var(--ink-muted)" stroke-width="1.25" marker-end="url(#lost-arr)"/>
    <line x1="318" y1="132" x2="458" y2="280" stroke="var(--ink-muted)" stroke-width="1.25" marker-end="url(#lost-arr)"/>
    <line x1="296" y1="176" x2="436" y2="320" stroke="var(--ink-muted)" stroke-width="1.25" marker-end="url(#lost-arr)"/>
    <line x1="276" y1="136" x2="418" y2="292" stroke="var(--ink-muted)" stroke-width="1.25" marker-end="url(#lost-arr)"/>
    <circle cx="284" cy="148" r="4" fill="var(--ink)"/>
    <circle cx="270" cy="168" r="4" fill="var(--ink)"/>
    <circle cx="318" cy="132" r="4" fill="var(--ink)"/>
    <circle cx="296" cy="176" r="4" fill="var(--ink)"/>
    <circle cx="276" cy="136" r="4" fill="var(--ink)"/>
    <circle cx="404" cy="276" r="4" fill="var(--ink)"/>
    <circle cx="388" cy="296" r="4" fill="var(--ink)"/>
    <circle cx="458" cy="280" r="4" fill="var(--ink)"/>
    <circle cx="436" cy="320" r="4" fill="var(--ink)"/>
    <circle cx="418" cy="292" r="4" fill="var(--ink)"/>
    <text x="372" y="452" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--ink-muted)">quality −15%</text>
    <text x="372" y="472" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--ink-muted)">flips +112%</text>
    <text x="372" y="492" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--ink-muted)">every model they tested</text>
  </g>
  <g class="diagram-mark">
    <rect x="516" y="8" width="196" height="484" rx="12" fill="var(--flow-io)"/>
    <text x="614" y="36" text-anchor="middle" font-family="var(--font-sans)" font-size="16" font-weight="500" fill="var(--flow-io-fg)">Over chat</text>
    <rect x="532" y="52" width="164" height="36" rx="8" fill="var(--ground)"/>
    <text x="544" y="74" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">Trying to build X.</text>
    <rect x="548" y="96" width="148" height="32" rx="8" fill="var(--rule)"/>
    <text x="560" y="116" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">What do you need?</text>
    <rect x="532" y="140" width="96" height="32" rx="8" fill="var(--ground)"/>
    <text x="544" y="160" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">Need A.</text>
    <rect x="548" y="180" width="108" height="40" rx="8" fill="var(--ground)"/>
    <rect x="560" y="192" width="72" height="6" rx="3" fill="var(--ink-muted)"/>
    <rect x="560" y="204" width="56" height="6" rx="3" fill="var(--ink-muted)"/>
    <circle cx="680" cy="200" r="12" fill="var(--ground)" stroke="var(--ink)" stroke-width="1.25"/>
    <path d="M675 195l10 10M685 195l-10 10" fill="none" stroke="var(--ink)" stroke-width="1.5" stroke-linecap="round"/>
    <text x="614" y="240" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--flow-io-fg)">too soon</text>
    <rect x="532" y="256" width="96" height="32" rx="8" fill="var(--ground)"/>
    <text x="544" y="276" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">Need C.</text>
    <rect x="548" y="296" width="108" height="40" rx="8" fill="var(--ground)"/>
    <rect x="560" y="308" width="72" height="6" rx="3" fill="var(--ink-muted)"/>
    <rect x="560" y="320" width="48" height="6" rx="3" fill="var(--ink-muted)"/>
    <circle cx="680" cy="316" r="12" fill="var(--ground)" stroke="var(--ink)" stroke-width="1.25"/>
    <path d="M675 311l10 10M685 311l-10 10" fill="none" stroke="var(--ink)" stroke-width="1.5" stroke-linecap="round"/>
    <text x="614" y="356" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--flow-io-fg)">guessed</text>
    <rect x="532" y="372" width="96" height="32" rx="8" fill="var(--ground)"/>
    <text x="544" y="392" font-family="var(--font-sans)" font-size="14" fill="var(--ink)">Need B.</text>
    <rect x="548" y="412" width="108" height="40" rx="8" fill="var(--ground)"/>
    <rect x="560" y="424" width="72" height="6" rx="3" fill="var(--ink-muted)"/>
    <rect x="560" y="436" width="40" height="6" rx="3" fill="var(--ink-muted)"/>
    <circle cx="680" cy="432" r="12" fill="var(--ground)" stroke="var(--ink)" stroke-width="1.25"/>
    <path d="M675 427l10 10M685 427l-10 10" fill="none" stroke="var(--ink)" stroke-width="1.5" stroke-linecap="round"/>
    <text x="614" y="472" text-anchor="middle" font-family="var(--font-sans)" font-size="14" fill="var(--flow-io-fg)">messy</text>
  </g>
</svg>
```

**A file is the whole brief in one go. Chat is the same brief, one piece at a time.** Microsoft Research and Salesforce ran the same jobs two ways, across 15 models ([Laban, Hayashi, Zhou, and Neville, 2025](https://arxiv.org/abs/2505.06120)). Hand over everything in the first message and it does the job. Drip the same facts across a conversation and the score falls 39% — and the answers swing around more than twice as much. Then they pasted those pieces into one ugly bullet list, no polish, and the score came back, about 95%. So it isn't the writing. It's handing everything over at once. That's `spec.md`, and it's why mine look like that cancel-order list.

The model also guesses on turn one and doesn't climb back out. That's the sixth controller method, measured.

**Gaps cost more than long prompts.** The same benchmark as the 44% found the other half. Splitting one task across two turns cost 31% more thinking. Restating the same requirements at length — a much longer prompt, no new information — cost nothing, about 1.0×. Length isn't what the model charges for. Undecided things are. I close those in the spec, not in prompt five.

**I can't feel which way a session went.** METR ran a real randomized trial ([Becker, Rush, Barnes, and Rein, 2025](https://arxiv.org/abs/2507.09089)): 16 experienced developers, 246 real tasks, in repositories they'd worked in for years. With AI tools they were 19% *slower*. They finished believing they'd been 20% faster. A file is something I can check tomorrow. My sense of how fast today felt isn't.

## What I don't want to see

- the coding model opened before `spec.md` exists
- a spec that pastes a controller, a validation class, or a migration
- the expensive model writing every line of the feature
- a cheap model handed a vague ticket and told to just build it
- the repository pattern explained again in prompt five
- a fix that lives in the chat instead of the file
- a different standard per teammate, because the rules lived in someone's history

None of that is the model's fault. It's starting in the wrong session, or putting code in the file that was supposed to constrain it. I write the spec first. Then I open the coding model.
