ZeNorm

How to write specs for coding agents

A practical guide to specs an LLM can execute — non-goals, verifiable criteria, named interfaces, and decisions with rationale that survive review.

Alex Earll11 min read

Most advice about writing specs for AI agents amounts to "be more specific," which is true, useless, and not actionable. Specific about what? A spec can be five thousand words of detail and still omit the one sentence that would have prevented the agent from inventing its own caching layer.

This is a guide to what actually goes in the document, structured around a single principle: a spec for an agent is a document that closes off invention. Every section exists to remove a degree of freedom the agent would otherwise fill in by itself, confidently and plausibly.

Why a spec for a human doesn't work on an agent

Hand a competent engineer a half-finished spec and they will do three things automatically. They fill gaps with shared context — they know your team uses cursor pagination, they know the billing service is fragile, they know last quarter's incident makes the retry question sensitive. They notice contradictions, because they are building a mental model and inconsistencies snag on it. And when a gap is too large to fill safely, they ask.

An agent does exactly one of these things, and it is the wrong one. It fills gaps. It fills them fluently, using the statistically most common resolution across everything it has ever read, which is usually reasonable and occasionally catastrophic for your specific system. It rarely notices contradictions because it is not maintaining a model of the whole document — it is generating locally plausible text. And it almost never asks, because a helpful assistant that produces working code scores better than one that asks five clarifying questions.

The consequences are specific:

  • Silence is not a question, it is a decision. Anything you leave out gets decided. Omission is not deferral.
  • There is no shared context to lean on. Every convention that lives in your team's collective memory rather than in a file is invisible.
  • Plausible is the failure mode, not wrong. Bad output that looks bad gets caught. Agent output that resolves an ambiguity incorrectly looks exactly like output that resolved it correctly.

So the job is not "write more detail." It is "identify every place a decision could be made, and make it." Detail without that targeting is padding — and padding actively hurts, because it dilutes the parts that matter.

The parts of a spec that earn their place

Six sections. Not a template to fill in mechanically; each one closes a specific class of invention, and a section that is not closing anything should be cut.

Problem statement: what is broken, for whom

Two or three sentences on the actual problem, not the proposed solution. This exists so the agent can make sensible local calls on the hundred micro-decisions you did not enumerate.

There is a difference between:

Add a retry wrapper to the webhook sender.

and:

Webhook deliveries to customer endpoints fail roughly 2% of the time due to transient network errors on the customer side. Those failures are currently permanent — the event is dropped and the customer's system silently diverges from ours. Support gets 3-4 tickets a week about missing data.

The first specifies a mechanism. The second explains the failure being prevented, which lets the agent judge whether a given edge case matters. Faced with "should a 400 be retried?", an agent with the second version can reason: a 400 is not a transient network error, so no. An agent with the first has nothing to reason from and will guess based on whatever retry code it saw most in training.

Keep it short. This is orientation, not history.

Non-goals: the highest-leverage section

If you write only one section beyond the problem statement, write this one. It is consistently the highest value per word in the entire document, and it is the section people skip.

Agents are trained to be thorough. Ask for a feature and you tend to get the feature plus an admin panel, plus a metrics hook, plus a config abstraction for things that will never be configured. Every one of these is code you now own, review, and maintain.

Non-goals are cheap to write and near-total in effect:

## Non-goals
- No admin UI for retry configuration. Values are constants in code.
- No changes to the webhook payload schema.
- No dead-letter queue in this change. Exhausted retries log and drop; the DLQ
  is tracked separately in PLAT-412.
- No new dependencies. Use the existing HTTP client in src/lib/http.ts.
- Do not touch the inbound webhook receiver. Different subsystem.

Note that the third one includes where the deferred work went. That matters: an agent told simply "no DLQ" may conclude the requirement was overlooked and helpfully add one anyway. An agent told the DLQ is tracked elsewhere understands the boundary is deliberate.

The best source of non-goals is your own experience of over-delivery. After a few features, you know which directions an agent wanders. Write those down once and reuse the list.

Acceptance criteria: assertions, not aspirations

Each criterion should be something you could hand to a stranger who could then determine pass or fail without asking you anything.

The test is mechanical: can you imagine two competent people disagreeing about whether it is satisfied? If yes, rewrite it.

## Acceptance criteria
- A webhook returning 500 is retried at 1s, 4s, and 16s, then abandoned.
- A webhook returning 400, 401, 403, or 422 is not retried. One attempt only.
- A webhook that times out (>10s, no response) is retried on the same schedule
  as a 500.
- Retry attempts carry the original X-Event-Id header, unchanged.
- After retries are exhausted, one error-level log is emitted containing the
  event id, endpoint URL, final status code, and attempt count.
- Retries for one endpoint never block delivery to a different endpoint.

Every one of those is checkable. Several map directly onto tests. Contrast with what usually appears in this section — "retries should work reliably," "handle errors gracefully" — which is decoration. The full treatment of what separates these is in acceptance criteria that actually work.

The additional payoff with agents is that a checkable criterion closes the verification loop without you. An agent can run its own work against "returns 409 on duplicate submission." It cannot run anything against "handles duplicates sensibly."

Edge cases: enumerate them, do not gesture at them

"Handle edge cases appropriately" transfers zero information. The agent already intended to handle edge cases appropriately; the disagreement is over what appropriate means.

List the actual cases with their actual expected behavior. A table works well because it makes empty cells visible:

SituationExpected behavior
Endpoint URL no longer resolves (DNS failure)Treat as transient. Retry.
Customer deleted the endpoint mid-retryAbandon remaining retries, log info
Event is retried after the customer rotates their signing secretSign with the current secret at send time, not the original
Two events for the same endpoint fail simultaneouslyIndependent retry schedules. No shared backoff.
Process restarts with retries in flightIn-flight retries are lost. Acceptable for this change.

That last row is doing real work. It is an edge case you have decided not to handle, stated explicitly. Without it, the agent sees a durability gap and either builds persistence you did not ask for or leaves the gap silently — and you cannot tell which happened without reading everything.

You will not think of every case. You do not have to. The goal is to cover the ones where a wrong guess is expensive, and to establish by example that this spec enumerates rather than gestures.

Interfaces: real paths, real names

Vague structural direction produces structural improvisation. Name the files:

## Interfaces
- Retry logic goes in `src/webhooks/retry.ts` (new file).
- `src/webhooks/sender.ts` calls it. `sendWebhook()` keeps its current
  signature — callers must not change.
- Backoff config as exported constants in `src/webhooks/retry.ts`:
  RETRY_DELAYS_MS = [1000, 4000, 16000].
- Use the existing logger from `src/lib/log.ts`. Do not add a logging dep.
- Tests in `src/webhooks/retry.test.ts`, colocated per repo convention.

Two things this buys you. First, the agent stops inventing project structure — otherwise you get a new src/utils/ directory in a repo that has deliberately avoided one. Second, "keeps its current signature" prevents the most annoying class of agent change: a refactor of the call site that was not in scope and now touches eleven files, of which you will review two.

If a file does not exist yet, say so. (new file) prevents the agent from searching, failing, and quietly picking a different location.

Decisions with rationale: stop the helpful undo

This is the section that specifically defends against context loss, and it belongs in the spec rather than in chat because the spec is what gets re-read.

Any choice that looks suboptimal from the outside needs its reason attached. Not for documentation's sake — to prevent an agent from "improving" it in a later session when the reasoning is no longer anywhere it can see.

## Decisions
- **Delays are constants, not config.** We have never changed them in two years
  and a config surface is more code to maintain. Do not add configurability.
- **Retries are in-process, not queued.** A queue is the right long-term answer
  but requires infra we don't have yet (PLAT-412). This is deliberately the
  cheap version.
- **Backoff is fixed, not jittered.** Our volume is low enough that thundering
  herd isn't a real risk, and fixed delays are testable without mocking random.
  If volume grows 10x, revisit.

The pattern is consistent: decision, reason, and an explicit instruction about whether to revisit. The third part matters more than it looks. A model reading "backoff is fixed" alone will often add jitter, because adding jitter is what the training data says you do. Reading the reason plus the revisit condition, it leaves it alone.

Before and after

The transformation is easier to see on one requirement than one document.

Before:

Users should be able to search their specs. It should be fast and handle typos.

Every substantive question is open. What fields are searched? Is search scoped to the user, the org, or everything? What does "fast" mean and under what data volume? What does "handle typos" mean — fuzzy matching, a did-you-mean, stemming? Empty query? What order are results in? How many? Does it match archived items?

An agent will answer all of these. It will probably add a debounce you did not ask for, pick a fuzzy-matching library, and put a LIMIT 50 in there because fifty is a number.

After:

Search specs by title and description.

Scope: specs in the caller's current org only. Never cross-org, including for admins.

Matching: case-insensitive substring on title and description. No fuzzy matching, no stemming, no synonyms — see Decisions.

Results: max 20, ordered by updated_at descending. Archived specs excluded.

Empty or whitespace-only query returns an empty array, not all specs.

Performance: p95 under 200ms at 10k specs per org. There is an index on (org_id, updated_at); add one for the text match if the query plan needs it.

Interface: GET /v1/specs/search?q=, handler in apps/api/src/routes/specs.ts alongside the existing spec routes.

Decisions: No fuzzy matching in this change. Our similarity layer already handles semantic search and adding a second fuzzy path would give two different notions of "similar" in one product. Substring search here is deliberately dumb. Do not add trigram or edit-distance matching.

Roughly four times the words, and the difference is not length — it is that the second version has no remaining decisions in it. Every place the agent would have guessed now has an answer. The "do not add" line specifically stops the most likely improvement it would have volunteered.

Note also how much of the "after" is answering questions the "before" did not raise. That is the actual work of spec-writing: not describing the feature more elaborately, but finding the decisions hiding inside it.

How much is too much

A spec that nobody maintains is worse than no spec, because it is confidently wrong. A stale spec claiming search excludes archived specs, in a codebase that now includes them, will actively cause an agent to "fix" working code.

Some working limits:

Specify what is expensive to get wrong. A wrong guess about auth scoping is a security incident. A wrong guess about button placement is a two-minute fix. Spend your words accordingly.

One page per unit of work, or the unit is too big. If the spec cannot fit in roughly a page, split the work. This is the same sizing pressure described in the 70% problem — small vertical slices fail visibly and early. It applies to specs for the same reason.

Never restate what the code already says. A spec that describes the current schema field by field is guaranteed to drift, and drifted detail is worse than absent detail. Point at the file instead. The code is the source of truth for what is; the spec is the source of truth for what should be and why.

Skip anything the conventions already cover. If your repo has an AGENTS.md or CLAUDE.md stating the package manager, the test framework, and the file layout, do not repeat it per spec. Repeated boilerplate trains readers — human and model — to skim, and skimming is how the important lines get missed.

Delete specs when the work ships. Or fold the durable parts (the decisions and their rationale) into a decision record or a comment at the relevant code, and delete the rest. A spec is scaffolding for a change. The parts worth keeping are the parts that explain a choice someone will otherwise want to undo.

The uncomfortable part

Most of the difficulty in writing a good spec is not writing. It is discovering that you do not know what you want at the level of precision the agent requires.

You sit down to specify search and realize nobody has decided whether admins can search across orgs. That question was always there. Previously it surfaced in code review, or in production, or as a security finding. Now it surfaces at the moment you try to write one sentence about scope — which is far cheaper, and also far more annoying, because it feels like the tool is being pedantic when it is actually being correct.

This is the honest reason spec-writing for agents is unpopular. It front-loads the thinking. The payoff is real but deferred, and the cost is immediate and feels like bureaucracy.

The mitigation that works best is not writing harder. It is being interrogated — having something ask the questions you would not have thought to ask, because you cannot enumerate your own blind spots by staring at a blank document. That can be a skeptical colleague, a checklist built from your last five features' worth of bugs, or a tool built for it. ZeNorm is our attempt at the last one: it grills you about intent, constraints, and edge cases before any code is generated, and outputs a spec with the answers in it. The mechanism matters less than the practice.

What does not work is skipping the step and hoping the model figures it out. It will figure it out. That is precisely the problem — it will figure out something, and you will not find out what until the last 30%.

Keep reading