Marcelo Pastorino, Software Developer

Writing documentation that AI agents actually follow

The living documentation process behind my starter kit, from a two-part docs split to a drift check that fires when I forget

Most of what an AI coding agent gets wrong in a real repository is not syntax. It is context. The code compiles, the type checker is happy, and the change still breaks a rule that exists nowhere except in the head of whoever wrote the last ten files.

Here is a concrete one from my own starter kit. Endpoints that return HTML fragments live in two directories, src/pages/public-features/ and src/pages/app/private-features/. That looks like a filing preference. It is not. The auth middleware protects the /app URL prefix, so the folder an endpoint sits in is what decides whether it requires a session. An agent that drops a change-password endpoint into the public folder produces code that works perfectly in the browser and silently removes authentication.

No prompt fixes that reliably. What fixes it is writing the rule down somewhere the agent has to read, and then making sure the writing does not rot. That is the whole process I built into my Starter Kit, and the part worth stealing is not the stack. It is the machinery that keeps the documents honest.

The documents are the source of truth

The repository opens with an AGENTS.md that calls itself an operating manual, and its first instruction is that agents must never invent project behavior. Everything an agent is allowed to assume comes from docs/. That framing matters more than it sounds. A prompt is advice. A source of truth is something you are wrong for contradicting.

The second half of that claim is the harder one. Documentation only works as a source of truth if it is current, so the repo treats a stale document as a bug, exactly like a failing test. Not a chore, not a nice-to-have for the next quarter. A defect.

Two kinds of documentation, split on purpose

The docs/ folder has two subfolders, and the split is the first real design decision.

Folder Describes Lifecycle
docs/engineering/ How the starter kit works, the technology Stable, reused across every project built on it
docs/product/ What is being built on top of the kit, the business Starts nearly empty, grows per project

Engineering docs are the rails. There are eleven of them, one per concern, and the names are boring on purpose so an agent can map a task to a document without guessing. ARCHITECTURE.md, STACK.md, SCHEMA.md, API.md, CONVENTIONS.md, DESIGN.md, UX.md, TESTING.md, SECURITY.md, COMMANDS.md, DEPLOYMENT.md, plus a DECISIONS/ folder for architecture decision records.

Product docs are FEATURES.md, PRD.md, and their own DECISIONS/ folder for business choices like pricing and scope. In the kit itself, PRD.md is a single heading and nothing else. That emptiness is deliberate. It is a slot waiting for the product, not an oversight.

The payoff of the split shows up when you fork the kit for a new product. Engineering docs travel unchanged. Product docs get rewritten from scratch. Nobody has to untangle which paragraphs described the rails and which described the cargo.

Documents that constrain, not documents that describe

A document that says “we use Astro partial routes” tells an agent nothing it could not have guessed. The ones that earn their place spell out the contract. ARCHITECTURE.md requires every feature route to export const partial = true, to use a single init() entry point, to dispatch on the request method, to delegate each verb to a named handler, and to return 405 from the default branch. CONVENTIONS.md then prints the exact shape.

const init = async () => {
   switch (request.method) {
      case "GET":
         break;
      case "POST":
         await handlePost();
         break;
      default:
         response.status = 405;
         break;
   }
};

const handlePost = async () => {
   // Handle POST.
};

await init();

Twenty lines of prose plus that snippet means an agent adding a PUT writes one switch case and one handlePut(), in the same shape as every other feature in the repository. Nobody negotiates the structure again.

The same instinct applies to the rules that carry risk. SECURITY.md does not say “be careful with Supabase”. It says create a fresh client per request, because session-setting methods mutate client state and a module-level client can leak identity between concurrent requests. It says the browser key is only safe when row level security is enabled and correct, and that you must never reach for a service key to work around a bad policy. Those are the sentences that stop an agent from shipping something plausible and dangerous.

The workflow, and the step that makes it stick

Every task runs through five steps. Understand the request and read the relevant docs first. Plan against existing patterns instead of inventing new ones. Implement the smallest change that fully solves the problem. Validate. Update documentation.

Four of those five are what any careful engineer already does. The fifth is where processes usually die, so it has its own machinery.

The task loop, with the documentation drift check as a gate

The drift check is four moves. List the files you changed, map each one through a trigger map to the documents it affects, open those documents and check only the sections that relate to the change, then record the outcome. It is targeted, not a full re-read, which is what makes it survivable on a small task.

The trigger map is the piece I would port to any repository first. It converts a vague obligation into a lookup.

If you changed Check these documents
System structure, module boundaries, request flow ARCHITECTURE.md, DECISIONS/
HTTP endpoints, request or response contracts API.md, FEATURES.md
Database tables, columns, migrations SCHEMA.md
Authentication, authorization, user data SECURITY.md
UI components, layout chrome, styling DESIGN.md, UX.md
Dev commands and scripts COMMANDS.md
A new pattern or architectural trade-off DECISIONS/, new or updated ADR

“Keep the docs updated” is an instruction an agent will agree with and then ignore. “You changed a migration, so open SCHEMA.md” is an instruction it can execute.

The forcing function is one line of output

Every task has to end with a Documentation impact line stating either which documents changed, or No doc impact with a one-line reason. It is listed as a required output, not a courtesy.

That single line does more work than the rest of the process combined. It makes the drift check observable. An agent can quietly skip a step it was told to perform, but it cannot produce the required output without at least deciding what the answer is. And No doc impact is a legitimate answer for a pure internal refactor, so there is no incentive to fake a documentation edit just to look compliant.

The process also defines what a trivial change is, which keeps the whole thing from collapsing under its own weight. Typo fixes, comment edits, whitespace, renaming a local variable with no cross-file impact. Anything that changes behavior, an endpoint, a UI element, a dependency, a schema, or a convention is not trivial. When in doubt, treat it as non-trivial. A process with no escape hatch gets abandoned on the first three-character fix.

Automating the nag, not the judgment

Because there is no automated documentation lint, the drift check is manual. To keep that from depending on memory, the repo registers a Cursor stop hook that inspects git status when a turn ends and classifies each changed path.

while IFS= read -r path; do
  [ -z "$path" ] && continue
  case "$path" in
    docs/*|AGENTS.md|README.md) docs_changed=true ;;
    .cursor/*|LICENSE.md) ;;
    *) code_changed=true ;;
  esac
done <<< "$changed"

if [ "$code_changed" = true ] && [ "$docs_changed" = false ]; then
  echo '{"followup_message":"Doc drift gate: ..."}'
fi

Code changed, no documents touched, send a follow-up message asking for the drift check. Otherwise stay silent. It depends only on bash, git, and sed, and it fails open, so a broken hook never blocks work.

What I like about it is how little it claims. The ADR that introduced it says outright that it is a reminder, not a validator, and that it cannot tell whether documentation content actually matches the implementation. It only detects the absence of a documentation edit. A prompt-based hook that judged doc coverage was considered and rejected as non-deterministic and slower for the same reminder value. A CI gate was rejected too, since the repo has no pipeline and a hard failure would block every legitimate No doc impact change.

That is the right shape for this kind of automation. Automate the thing a heuristic can decide with certainty, and leave the judgment to the step that already exists. It is the same instinct behind infrastructure as code and CI/CD pipelines, where the value comes less from cleverness than from a check that runs whether or not anyone remembers it.

Decision records catch what the other documents cannot

Reference documents say what is true now. They are bad at explaining why the obvious alternative was rejected, which is exactly the knowledge an agent needs before it “improves” something on your behalf.

The kit uses ADRs with a fixed shape, numbered per area, with Status, Context, Decision, Alternatives considered, and Consequences. The instruction is explicit about not documenting only the final decision.

The third one is the best illustration of why. Preparing a 0.0.1 release surfaced two ways the repo behaved like a deployed product instead of a starter kit. The product domain appeared in roughly fifteen places, and the static robots.txt and sitemap.xml had drifted to disagree with all of them. Worse, the auth middleware read a user_profiles table on every request, SCHEMA.md described it, and no SQL for it existed anywhere in the repository. It lived in one Supabase dashboard. A fresh clone would build, boot, and then fail on every authenticated request.

The fix was a single src/config/site.ts holding product identity, plus versioned migrations in supabase/migrations/. But the ADR is more useful than the fix. It records that environment variables were rejected for the site identity because a domain is not a secret and a rebrand should be visible in git history. It records that a find-and-replace checklist was rejected because the audit proved the checklist would already have been wrong. And it lists the consequences honestly, including that the sitemap is now hand-maintained, so a new page nobody adds will not be indexed.

Six months later that document answers the question an agent would otherwise resolve by guessing.

What it costs

This is not free. Every non-trivial task pays a reading tax at the start and a writing tax at the end. The stop hook keys off the whole working tree, so unrelated work in progress can trigger a reminder on a turn that genuinely had no documentation impact, and you spend a short exchange saying so. The drift check is only as good as the person or agent performing it, since nothing verifies that an updated paragraph is actually correct.

The trade I am making is that those costs are small, frequent, and visible, while the cost of a stale document is large, rare, and invisible until an agent confidently builds on top of a sentence that stopped being true two months ago.

The deeper shift is treating documentation as an interface rather than a deliverable. Not something you produce for a reader who might show up someday, but the thing your collaborators load before they touch anything, one of them being a model with no memory of yesterday and no way to sense the conventions you never wrote down. Written that way, docs stop being overhead. They become the part of the repository that makes everything else reproducible.

How do you keep documentation current when AI agents write most of the code?