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.jsonand the code AST collapse into a singleagent_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.
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:
| Source | Section | Path |
|---|---|---|
openapi.json | @api, @types | deterministic |
| DTO and decorator AST | @types, @api | deterministic |
docs/events.md + consumers | @events | deterministic + narrated |
| Migrations / ORM entities | @db | deterministic |
| Environment variable schema | @config | deterministic |
docs/architecture.md | @purpose, @deps | narrated |
docs/flows.md | @flow | narrated |
docs/decisions/*.md | @invariants | narrated |
docs/operations.md | @gotchas, @ops | narrated |
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
@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 codeExample
@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 checkoutThat 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:
| Model | Input / Output per MTok | Per 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:lineexists in that commit’s tree. - Exact parity between
@apiandopenapi.json: same paths, methods, and codes. - Parity between
@typesand the AST DTOs: same fields, same optionality. - Parity between
@eventsand the topics actually produced or consumed. - Parity between
@dband the latest applied migration. @configagainst 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:
code > agent_summary > docs/*.md > wikiThings 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.mdcommitted 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.