DEV Community

pickuma
pickuma

Posted on • Originally published at pickuma.com

Spec-Driven Development With AI Agents: Writing a Spec an Agent Can Actually Execute

Most "specs" handed to a coding agent are wishes. "Add rate limiting to the API" is a wish. The agent will produce something — a middleware file, probably in-memory, probably with a test that asserts the middleware exists — and you will spend longer reviewing it than you would have spent writing it yourself.

A spec an agent can execute end to end is a different artifact. It names the files that already exist, states one acceptance check, and closes every decision the agent would otherwise make on your behalf. We rewrote our own workflow around this on a TypeScript codebase (Astro front end, Supabase backend) after too many twenty-minute unattended runs came back with plausible code and no working feature. What follows is the structure that survived.

The five parts of an executable spec

1. Goal and non-goal, one line each. The goal is the behavior change, stated from the outside: "A client that sends more than 60 requests per minute to /api/track gets a 429 with a Retry-After header." The non-goal is the adjacent work you do not want touched: "Do not add rate limiting to any other route. Do not change the response shape of successful requests." Agents expand scope when the boundary is implicit; the non-goal line is cheap and it holds.

2. Ground truth: the files that already exist. List the three to six files the agent should read before writing anything, and say what each one is for. This is the single highest-leverage part of the spec. Without it, the agent greps, finds a pattern from a file you abandoned six months ago, and copies it. With it, you get code that looks like the rest of your codebase because it was told which code to look like.

3. The interface contract. Function signatures, table columns, env var names, error shapes — written out, not described. If the agent has to invent a name, it will invent a different one in the implementation than in the test, then spend three tool calls reconciling them. Write rateLimit(key: string, limit: number, windowMs: number): Promise<{ allowed: boolean; retryAfter: number }> and that whole class of thrash disappears.

4. Exactly one acceptance command. Not "make sure tests pass." A literal string the agent can paste into a shell: bun test src/lib/rate-limit.test.ts. The agent needs a signal it can produce itself, on demand, without asking you. If the work can't be reduced to one command, the spec is too big — split it.

5. Out of bounds. Files or directories that must not change: migrations already applied, generated files, anything with a hand-tuned config. Agents treat a failing build as a puzzle, and deleting your tsconfig strictness flag is a valid solution to that puzzle.

Write the acceptance command before you write the implementation section of the spec. If you can't state the command, you don't yet know what "done" means — and neither will the agent. This one reordering caught more bad specs for us than any review step.

Four failure modes and the spec line that fixes each

These are the patterns we saw repeatedly across runs, and the specific sentence that stopped each one.

Failure mode What the agent does Spec line that prevents it
Invented abstraction Adds a RateLimiterFactory, a strategy interface, and a config object for one call site "Single exported function. No new classes, no config objects, no new directories."
Silent scope creep Refactors the surrounding module "while it was in there" "Only these files may change: <list>. Report anything else you believe needs changing; do not change it."
Test theater Writes a test that asserts the function is defined and returns an object "The test must fail if the limit is off by one. Include a case at limit-1, at limit, and at limit+1."
Green-by-deletion Makes the build pass by loosening a type, skipping a test, or removing an assertion "Do not modify existing tests, tsconfig.json, or lint config. If an existing test blocks you, stop and report it."

The last one deserves emphasis. An agent optimizing for a green acceptance command has two paths: fix the code, or weaken the check. It will take whichever is shorter. Your spec is the only thing that closes the second path.

An acceptance command that can pass trivially is worse than no acceptance command, because it converts "unverified" into "verified" in your head. Before you hand the spec over, ask: could this command pass with an empty implementation? If yes, tighten the assertion, not the prompt.

Running it: checkpoints, and what to do when it stalls

Hand the spec over as a file in the repo, not as a chat message. A file survives context compaction, gets read again when the agent re-orients mid-run, and — importantly — can be diffed. When a run goes wrong, you want to compare the spec you thought you wrote against the one on disk.

Structure long specs as checkpoints rather than one blob: after each numbered step, state the observable result. "Step 2 done means bun test src/lib/rate-limit.test.ts passes and no other file has changed." The agent gets intermediate signal, and you get a resume point that isn't "start over."

When you review, read the diff, not the transcript. The transcript is the agent's account of its own work and it is uniformly optimistic. The diff is what shipped. This sounds obvious and it is still the discipline people drop first when a run looks like it went well.

The rule that saved us the most time: if you've corrected the agent twice in chat, stop and rewrite the spec. Two corrections means the spec was ambiguous, and a third chat message patches this run while leaving the ambiguity in place for the next one. Rewriting the spec and restarting from a clean context is almost always faster than steering — the run that went sideways is carrying a context full of its own wrong turns.

One practical note on tooling: this workflow works better with agents that read project-level instruction files (AGENTS.md, CLAUDE.md) automatically, because your standing rules — package manager, test runner, forbidden patterns — live there instead of being restated in every spec. The spec then covers only what's specific to this task, which is how it stays short enough that you actually write one.

Spec-driven development isn't a productivity trick, and it doesn't make agents smarter. It moves the thinking earlier: the ambiguity you don't resolve in the spec gets resolved by the agent, at random, in code you then have to read. Writing the spec is the same work either way — you just get to decide whether you do it before or after the diff exists.


Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Top comments (0)