Paul Mosquera Staff Applied AI & Platform Engineer
All writing

AI Wrote Your Docs. Now Compile Them.

The next step once you already generate your microservice documentation with AI

  • agents
  • documentation
  • microservices
  • context

Your microservice documentation is generated, committed, and almost certainly accurate. It’s also written for a reader who stopped being the main one. The thing that opened it this morning wasn’t a person — it was an agent, and it doesn’t want prose, doesn’t skim, and can’t afford the round trip to the code that a human makes without thinking.

That’s not a documentation problem. It’s a build problem, and it has a build solution.

What this post covers:

  • Why the per-service summary should be treated as a build artifact, not a document
  • The compile chain: how docs/*.md, openapi.json and the code AST collapse into a single agent_summary
  • A dense notation for it, with a full worked example you can copy
  • The one rule that decides whether it stays true: what goes in complete, and what goes in thin
  • Parity validators that turn a stale summary from a discipline problem into a red build
  • What a compile actually costs per release, across Haiku 4.5, Sonnet 5, Luna and Terra

If you don’t have a documentation pipeline yet, this is the wrong post — start there and come back. If you do, the next section is probably a description of your current situation.


Where we are

This isn’t an article about why you should document. It assumes you’re well past that: you have a pipeline that runs a model over each repo, generates or updates the service documentation, and commits it next to the code. You probably already have a decent docs/ folder per microservice, covering architecture, flows, exposed APIs, events, and the data model. Maybe even a per-service summary file meant to be read by an agent.

If that’s you, you probably also recognize three symptoms.

The summary reads like an essay. A model wrote it, and it came out shaped for a human reader: paragraphs, transitions, “this microservice is responsible for…“. It costs thousands of tokens and half of them are connective tissue.

It’s correct, and written for the wrong reader. Assume every word of it is true — with current models that’s a fair assumption. It’s still shaped for a person: narrative order, abstraction exactly where a human wants abstraction, detail dropped exactly where a human would just go open the file. Every one of those is a convenience for the human reader and a lookup miss for the machine one. An agent doesn’t skim, doesn’t infer from surrounding context, and can’t cheaply “just open the file.”

So the agent opens twenty files anyway. Documentation written for humans describes; it doesn’t specify — and it stops precisely where a human stops needing it. “Receives the order details” is a complete sentence for a person, who was going to look at the DTO regardless. For an agent about to write a line of code it’s a pointer, not an answer: it still needs the field name, whether it’s optional, and what comes back on failure. So the file gets loaded, and then bypassed. You paid to generate the docs, and then paid again to not use them.

All three symptoms share one root cause: we’re treating the summary as a document when it’s really a build artifact.


The reframe

Microservice documentation isn’t a document. It’s source code for an artifact that gets compiled.

The files in docs/ are the sources. A model may well have written them — that’s the normal case — but they’re structured the way people read, they land in a pull request, and a human can push back on what the model chose to emphasize or leave out. The artifact is what the pipeline compiles out of them: a dense format nobody would enjoy reading, that costs an agent a fraction of the tokens, and that no one is expected to review line by line.

text
docs/*.md + openapi.json + code AST  ──►  agent_summary.md
     (sources, for humans)                (artifact, for machines)

And like any build artifact: nobody edits it by hand, it regenerates on every merge, and deleting it loses nothing at all.


The derivation chain

flowchart LR
    A["CURATED DOCS<br/>─────────────<br/>architecture.md<br/>flows.md<br/>apis.md<br/>events.md<br/>data-model.md<br/>operations.md<br/>decisions/*.md"]
    B["REPO ARTIFACTS<br/>─────────────<br/>openapi.json<br/>DTO AST<br/>migrations<br/>env schema"]
    C(["CONTEXT<br/>COMPILER"])
    D["agent_summary.md<br/>─────────────<br/>@types @api @events<br/>@deps @db @config<br/>@flow @invariants<br/>@gotchas"]

    A ==> C
    B ==> C
    C ==> D

    classDef docs fill:#EEF2FF,stroke:#818CF8,stroke-width:2px,color:#3730A3
    classDef repo fill:#ECFDF5,stroke:#34D399,stroke-width:2px,color:#065F46
    classDef comp fill:#1E293B,stroke:#0F172A,stroke-width:2px,color:#F8FAFC
    classDef out fill:#FEF3C7,stroke:#F59E0B,stroke-width:2px,color:#78350F

    class A docs
    class B repo
    class C comp
    class D out

Each source feeds different sections of the final artifact:

SourceSectionPath
openapi.json@api, @typesdeterministic
DTO and decorator AST@types, @apideterministic
docs/events.md + consumers@eventsdeterministic + narrated
Migrations / ORM entities@dbdeterministic
Environment variable schema@configdeterministic
docs/architecture.md@purpose, @depsnarrated
docs/flows.md@flownarrated
docs/decisions/*.md@invariantsnarrated
docs/operations.md@gotchas, @opsnarrated

One run, two stages

Worth being explicit about something the diagram compresses: in most setups already doing this, nobody writes docs/*.md from scratch. The same agent run that reads the repo produces them, and then — in the same run — takes its own output plus the deterministic extracts and emits the agent_summary.

flowchart LR
    R["repo"] ==> S1(["STAGE 1<br/>read the code<br/>write the docs"])
    S1 ==> D["docs/*.md<br/>human-readable<br/>reviewable in the PR"]
    D ==> S2(["STAGE 2<br/>compile"])
    E["deterministic<br/>extracts"] ==> S2
    S2 ==> A["agent_summary.md<br/>machine-readable"]

    classDef repo fill:#ECFDF5,stroke:#34D399,stroke-width:2px,color:#065F46
    classDef stage fill:#1E293B,stroke:#0F172A,stroke-width:2px,color:#F8FAFC
    classDef docs fill:#EEF2FF,stroke:#818CF8,stroke-width:2px,color:#3730A3
    classDef out fill:#FEF3C7,stroke:#F59E0B,stroke-width:2px,color:#78350F

    class R,E repo
    class S1,S2 stage
    class D docs
    class A out

Which raises the obvious objection: if a model wrote the docs and a model compiles them, why keep the middle step at all? Why not go straight from code to agent_summary?

Three reasons the intermediate stage earns its place.

It’s the only reviewable surface. A human can read a diff on architecture.md and disagree with it — with what got emphasized, what got flattened, which behavior was treated as incidental. Nobody is going to do that on a dense @flow block. Collapse the two stages and every judgment call the model makes lands directly in a file no person will ever meaningfully read.

It turns stage 2 into compression instead of comprehension. Stage 1 is the hard problem: reading unfamiliar code and working out what it does. Stage 2 is reformatting text that already exists into a stricter shape. That’s a much easier task, it fails in more obvious ways, and it’s the reason a cheaper model does it reliably.

The docs have a second audience anyway. New hires, architects reviewing a design, someone doing an incident post-mortem. That output isn’t waste you generate to feed the compiler — it’s a deliverable that happens to also be the compiler’s input.

One rule keeps the two stages from compounding errors: stage 2 must not add facts. Every claim in the narrated sections of the agent_summary has to trace back to a sentence in the stage-1 docs or to a deterministic extract. If the compiler is inferring something new at compile time, it’s doing stage 1’s job in the one place nobody is watching.

And there’s one thing stage 1 doesn’t own: the decisions. ADRs, invariants and the hard-won gotchas are the team’s memory, not something derivable from the current state of the code. The agent can propose them; a human merges them.


Two paths, and why they matter

This is the design decision that determines whether the whole thing works or rots.

flowchart TB
    subgraph DET["DETERMINISTIC PATH · no LLM"]
        direction LR
        D1["openapi.json<br/>AST · migrations · env"] --> D2["@api · @types · @events<br/>@db · @config"]
    end
    subgraph NAR["NARRATED PATH · with LLM"]
        direction LR
        N1["architecture.md · flows.md<br/>ADRs · operations.md"] --> N2["@purpose · @flow<br/>@invariants · @gotchas"]
    end
    D2 ==> M(["Assembler"])
    N2 ==> M
    M ==> V{"Parity<br/>validators"}
    V ==>|"divergence"| X["Pipeline fails"]
    V ==>|"pass"| Y["agent_summary.md<br/>committed"]

    classDef det fill:#ECFDF5,stroke:#34D399,color:#065F46
    classDef nar fill:#FEF2F2,stroke:#FCA5A5,color:#991B1B
    classDef mid fill:#1E293B,stroke:#0F172A,color:#F8FAFC
    classDef bad fill:#FEE2E2,stroke:#EF4444,color:#7F1D1D
    classDef good fill:#FEF3C7,stroke:#F59E0B,color:#78350F

    class D1,D2 det
    class N1,N2 nar
    class M,V mid
    class X bad
    class Y good
    style DET fill:#F8FAFC,stroke:#CBD5E1,color:#475569
    style NAR fill:#F8FAFC,stroke:#CBD5E1,color:#475569

Anything mechanically derived goes in complete, exact, and verbose. Input and output types, response codes, topics, columns: all of it is extracted by a parser. If the pipeline regenerates it on every merge, it cannot drift away from the code — that’s a guarantee no amount of model quality gives you. Inlining it is what kills the third symptom above — the agent no longer opens twenty files to find a field name — and that saving more than pays for carrying the full type table.

Anything narrated by the model goes in thin, with a pointer to the code. Purpose, flows, gotchas, invariants. Not because it’s likely to be wrong, but because nothing can check it, so it’s the one part that can quietly stop matching reality without anyone finding out.

So the rule isn’t “summarize more” or “summarize less.” It’s: if a parser can verify the claim, write it in full; if it can’t, write it short and leave the file path.


The format

The notation below is dense but readable by both humans and models with no prior training. A model parses it without special instructions; you just supply the legend once in the system prompt.

Legend

text
@section       section marker
field:type!    required
field:type?    optional
field:type=x   with default
→              produces / returns
|              alternative or metadata separator
{...}          object shape
enum(a,b,c)    allowed values
src path:line  pointer to code

Example

text
@svc order-service | team-checkout | node20/nestjs10
@commit a4f19c2 | 2026-09-04T10:12Z | gen v3.1 | fresh
@card Orchestrates the order lifecycle: validates, authorizes payment against
      the gateway, persists, and emits the confirmation event.

@types
Money{amount:int! cents|currency:enum(USD,EUR)!}
OrderStatus enum(DRAFT,PENDING,AUTHORIZED,CONFIRMED,FAILED,ON_HOLD)
Order{id:uuid! customerId:str! total:Money! status:OrderStatus!
      idemKey:uuid! meta:map<str,str>? createdAt:iso! confirmedAt:iso?}
CreateOrderReq{customerId:str! items:Item[]! idemKey:uuid!
               couponCode:str? expiresIn:int=900 sec}
Item{sku:str! qty:int! unitPrice:Money!}
OrderRes{id:uuid! status:OrderStatus! total:Money! expiresAt:iso!}
ErrRes{code:str! message:str! traceId:str!}

@api base=/v1 auth=jwt:customer
POST /orders   in:CreateOrderReq → 201:OrderRes | 409:ErrRes(DUP_IDEM)
               | 422:ErrRes(VALIDATION) | 502:ErrRes(UPSTREAM_DOWN)
               idem:header X-Idempotency-Key, cache 24h
               src src/orders/orders.controller.ts:42
GET  /orders/:id → 200:Order | 404:ErrRes
               src src/orders/orders.controller.ts:88
POST /orders/:id/cancel → 202:{} | 409:ErrRes(NOT_CANCELABLE)
               guard status in(PENDING,AUTHORIZED)
               src src/orders/orders.controller.ts:114

@events
out orders.confirmed.v1 OrderConfirmed{orderId:uuid! total:Money! at:iso!}
    key=orderId | src src/events/confirmed.publisher.ts:21
out orders.failed.v1    OrderFailed{orderId:uuid! reason:str! retryable:bool!}
    key=orderId | src src/events/failed.publisher.ts:18
in  payments.authorized.v2 PaymentAuthorized{orderId:uuid! authCode:str!}
    retry=3 exp | dlq=payments.authorized.dlq | idem by orderId
    src src/consumers/authorized.consumer.ts:33

@deps
payment-gateway http/sync BLOCKING to=3s retry=0 cb=5err/30s fallback=none
ledger-service  http/sync BLOCKING to=5s retry=2 exp cb=yes  fallback=none
cache           kv        DEGRADED usage=idempotency,locks
db              sql       BLOCKING pool=20 schema=orders
broker          kafka     DEGRADED outbox pattern, non-blocking

@db orders
orders(id pk uuid, customer_id idx, status idx, idem_key uniq, total_cents,
       currency, meta jsonb, created_at idx, confirmed_at)
order_items(id pk, order_id fk idx, sku, qty, unit_price_cents)
outbox(id pk, topic, payload jsonb, published_at null idx)
migrations src/db/migrations/

@config
GATEWAY_BASE_URL! GATEWAY_TIMEOUT_MS=3000 CACHE_URL! IDEM_TTL_S=86400
ORDER_EXPIRY_S=900 BROKER_URLS! FEATURE_CANCEL_ENABLED=false

@flow order.create
1 POST /orders → validate CreateOrderReq
2 cache SETNX idem:{idemKey} ttl=24h → if present: 409 DUP_IDEM
3 db INSERT order status=DRAFT (tx open)
4 payment-gateway POST /authorize {Money} → 200 authCode | 4xx→FAILED | 5xx→502
5 status=PENDING, outbox INSERT orders.confirmed.v1 (same tx) → commit
6 → 201 OrderRes

@flow order.confirm
1 consume payments.authorized.v2
2 db SELECT order FOR UPDATE → if status!=PENDING: idempotent skip
3 ledger-service POST /entries → on failure: status=ON_HOLD, no retry
4 status=CONFIRMED, outbox orders.confirmed.v1 → commit

@invariants
- idemKey unique for 24h; a resend after TTL creates a new order. Known, accepted.
- ON_HOLD is only cleared by the nightly reconciliation job. Never by hand.
- Never publish to the broker outside the tx: always outbox. See ADR-014.
- Amounts are always integer cents. No floats in the domain.

@gotchas
- The gateway returns 200 with the error in the status field. Check the body.
- FEATURE_CANCEL_ENABLED has been false in prod since the July incident.

@ops slo=99.9 p99=800ms
runbook docs/operations.md | alerts on-call checkout

That file carries the exact input and output types, the error codes, the events with their shapes, the data model, the configuration, the flows, and the gotchas. An agent can write correct code against the service without opening a single file, and when it needs the fine detail it has the src pointers to go straight there.

The @card line at the top is a deliberate exception: it exists for when an agent needs to survey many services at once and can’t load them in full. How to exploit that is the subject of the next post.


What this actually costs

The unit that matters is one service, one merge to main. This has to run on every promotion to production — that’s the whole point of the freshness guarantee — so the number to budget is cost per release, not cost per month.

The two-path design is also a cost design: the deterministic path spends nothing on inference. @api, @types, @events, @db and @config come out of parsers. You only pay a model for the narrated sections.

Even so, a real compile is not one cheap call. The model reads the docs, walks the parts of the code the docs point at, and iterates across several turns, so cumulative usage for an average service lands somewhere around 400k–700k input tokens and 40k–70k output tokens per run. On Haiku 4.5 that works out to roughly $0.60–$1.05, which matches what this costs in practice.

Standard list rates, verified September 2026, same workload:

ModelInput / Output per MTokPer service, per merge
GPT-5.6 Luna$0.20 / $1.20$0.13 – $0.22
Claude Haiku 4.5$1.00 / $5.00$0.60 – $1.05
Claude Sonnet 5$2.00 / $10.00$1.20 – $2.10
GPT-5.6 Terra$2.00 / $12.00$1.28 – $2.24

Batch pricing halves all of it on both providers, and regeneration is a near-perfect batch candidate if you can tolerate a nightly rebuild instead of one per merge.

Four things the table doesn’t show:

  • Tokenizer inflation. Claude 4.7 and later, including Sonnet 5, use a newer tokenizer that produces roughly 30% more tokens for the same text. Haiku 4.5 uses the previous one. Adjusted, Sonnet 5 lands closer to $1.55–$2.75 per run. Don’t compare headline rates across tokenizers and call it done.
  • Long context. OpenAI meters long-context requests at a higher rate — Terra jumps to $4/$18, Luna to $0.40/$1.80. Anthropic includes the full 1M window at standard rates on current models. At 400k+ cumulative input this is not a footnote.
  • Caching. Both providers charge about 10% of the input rate for cache reads. The system prompt, the notation legend, and the few-shot examples are identical for every service, so they should be cached on every call.
  • This is not a quality comparison. These are rates, not benchmarks. Run your own golden questions before choosing.

The cost that actually matters

Compiling the summary is the cheap part. The expensive part is every agent reading it, on every task, forever.

Per 1,000 agent reads, a summary that weighs 3k tokens instead of 8k costs $3 instead of $8 on Haiku, and $6 instead of $16 on Sonnet 5. A service under active development easily clears a thousand reads in a week, so the discipline of keeping the artifact dense pays back faster than any model swap.

That’s the whole argument for the format, in one line of arithmetic.

The recommendation

Haiku 4.5 if cost is a real constraint. The narrated sections are short and heavily grounded — you’re handing the model curated docs and asking it to compress, not to reason from scratch. Haiku handles that shape well, and under a dollar per production release is not a line item anyone argues about.

Sonnet 5 as the default preference. The sections that carry the most value per token — @invariants and @gotchas — are exactly the ones that need judgment about what matters and what a future reader will trip over. That’s where a stronger model earns its keep, and it costs roughly a dollar more per release. That’s a rounding error next to a single engineer-hour spent debugging a wrong assumption that a sharper summary would have flagged.

Luna is several times cheaper than anything else here and worth putting through your golden questions before you dismiss it. But at these absolute numbers, optimizing the compile bill is not where the leverage is. Optimizing the artifact size is.


The validators are the pattern

Putting exact contracts in the summary is only safe if divergence from the code breaks the build. Without that, don’t do any of the above: a file declaring a field that no longer exists is worse than no file at all, because the agent trusts it and doesn’t verify.

  • Every src path:line exists in that commit’s tree.
  • Exact parity between @api and openapi.json: same paths, methods, and codes.
  • Parity between @types and the AST DTOs: same fields, same optionality.
  • Parity between @events and the topics actually produced or consumed.
  • Parity between @db and the latest applied migration.
  • @config against the environment variable schema.

All of it runs in the pipeline, on every merge to main. That’s what makes it safe to hand an agent a contract instead of a description: not that the model got it right, but that nothing can quietly drift away from the code without the pipeline saying so.


Freshness as state

stateDiagram-v2
    direction LR
    [*] --> fresh
    fresh --> stale: HEAD moved on
    stale --> fresh: regenerated in CI
    stale --> rotten: commit or age threshold
    rotten --> fresh: forced regeneration
    rotten --> [*]: pulled from circulation

    classDef ok fill:#DCFCE7,stroke:#22C55E,color:#14532D
    classDef warn fill:#FEF3C7,stroke:#F59E0B,color:#78350F
    classDef bad fill:#FEE2E2,stroke:#EF4444,color:#7F1D1D

    class fresh ok
    class stale warn
    class rotten bad

The header carries the source commit and the state. The agent compares it against the service HEAD before using the file:

  • fresh — use the inline contracts without verifying. This is the normal case, and the one that makes the whole thing pay off.
  • stale — use the file as a map only, and verify any type or contract against the code before writing.
  • rotten — don’t use it. Better for the agent to say “I have no context on this service” than to work from a six-month-old snapshot.

With validators in CI and regeneration on every merge, stale should last minutes rather than being the steady state.


How it gets consumed, in one line

The agent loads the agent_summary for the service it’s working on, takes the contracts and gotchas from there, and only opens code files when it needs to see the exact implementation of one specific point. The summary narrows the search space; the code remains the source of truth, and that’s declared explicitly in the system prompt:

text
code > agent_summary > docs/*.md > wiki

Things get interesting when the agent has to reason across several services at once, and that deserves its own post.


Antipatterns

Editing the agent_summary by hand. It’s a build artifact. If something is wrong, fix the source or the compiler. A .gitattributes entry marking it as generated helps keep people from trying.

Letting the LLM write @types or @api. Those sections come from a parser. The moment a model drafts a contract, the contract stops being mechanically checkable — and checkable is the entire reason an agent can use it without re-reading the code.

Letting an agent merge its own ADRs. Stage 1 owns the descriptive docs. Decisions, invariants and gotchas are the team’s memory — the agent may propose them, but a human merges. It’s the only layer nothing can validate mechanically, which makes it the only one where review is mandatory.

Generating without an owner. The agent_summary belongs to the team that maintains the service, exactly like its tests.

Not measuring. A set of 10 to 15 questions per service with verifiable answers, run every time the compiler or the prompt changes. Without that, touching the generator means changing the system blind.


Checklist

  • docs/ split into separate sources: architecture, flows, APIs, events, data, operations, ADRs
  • Compiler with two paths: deterministic extractors and LLM-narrated assembly
  • agent_summary.md committed in the repo and marked as generated
  • Header with source commit, timestamp, and generator version
  • Parity validators blocking the merge
  • Automatic regeneration on every merge to main
  • Freshness states and a retirement policy
  • Source precedence and notation legend in the agents’ system prompt
  • Golden questions per service and periodic evaluation

Closing

If you already generate documentation with AI, your problem isn’t coverage. It’s that you’re producing prose when you should be producing an interface.

The sources stay readable, because that’s the only place a human still gets a say before the shape hardens. The artifact is compiled out of them. Exact contracts go in complete, because a parser emits them and the build fails when they diverge. The prose stays short, because nothing can validate it.

An agent_summary that regenerates on every merge, and whose contracts break the build when they drift, isn’t documentation. It’s an interface.


Pricing verified September 2026 against Anthropic’s pricing page and published OpenAI GPT-5.6 rates. Rates move; re-run the arithmetic before you quote it.