The docs convention
document:docs/docs-conventionEvery repository owns its docs/; one site aggregates them. This site is the proof.
The same argument as the event catalog, applied to prose: documentation belongs in the repository it describes, and a central site should read it rather than ask anyone to copy it.
A repository publishes by committing markdown to docs/. That is the whole
enrolment step. The site you are reading is built this way — these pages live in
docs/ at the root of the autocatalog repository, and the site reads them.
What is a fact, and what is authored
Section titled “What is a fact, and what is authored”owner is required frontmatter, because a document nobody owns is a document
nobody fixes. updated is never authored: it comes from git, per file, so a
freshness date cannot be wrong. Backlinks are derived — “linked from” is computed
across every enrolled repository, so nobody maintains the other end of a link.
Cross-repository links are declared as ids, not paths:
---title: Payments service runbookowner: Paymentsrelated: - acme-handbook/incident-communications---A relative path into a sibling checkout only resolves for someone who has every repository cloned side by side. An id resolves anywhere, and a link to a document that does not exist is a failing check rather than a dead link on a page.
A runbook or ADR is usually about something other than a document, so about:
attaches it to that resource, named as kind/id the way the catalog already
names it — an Event as event/<domain>/<id>, the domain/id an Agent’s
consumes uses:
---title: Payment capture runbookowner: Paymentsabout: - event/payments/payment.captured - service/PaymentService - agent/refund-agent---The document is then listed on that event’s and service’s pages, the resources
it is about are listed under the document, and /catalog.json carries each as a
describes edge. ac docs check fails a target this repository defines nothing
by, with the near miss it probably meant; a kind the repository defines none of
is left to the composed site, which warns and leaves the edge out.
A repository describes itself
Section titled “A repository describes itself”docs/meta.json is where a team names its documents, so nobody edits the site
to join it. Every field is optional:
{ "$schema": "https://unpkg.com/autocatalog-docs-core/contracts/docs-meta.schema.json", "label": "Payments", "description": "How the payments platform works, from batch submission to payout.", "owner": "payments", "sidebar": [ { "directory": "guide", "label": "Start here" }, { "directory": "runbooks", "label": "Runbooks", "collapsed": true } ]}label and description are what readers see in the navigation and on the home
page. owner owns every document here that names none of its own, so one line
covers a repository and a page can still name another team. sidebar orders the
folders of docs/; a folder it leaves out still appears, last and collapsed, so
a new section is never hidden. ac docs check fails a file that does not parse
or a directory that is not a folder directly under docs/.
Decisions, runbooks and incidents
Section titled “Decisions, runbooks and incidents”Architecture decision records, runbooks and incident write-ups live in the
repository they are about, in a folder under its docs/:
payments-service/docs/ adr/0003-kafka-for-payment-events.md runbooks/replay-payment-events.md incidents/2026-09-14-duplicate-payment-completed.mdThe folder sets the document’s kind, so nobody writes kind: by hand. A
document elsewhere can still name one in frontmatter (kind: incident), and
the deepest matching folder wins when two kinds nest. A folder’s index.md or
README.md introduces the kind and stays a plain document.
Each kind adds fields to the base contract:
| Kind | Folder | Fields |
|---|---|---|
adr | adr/ | status (proposed, accepted, rejected, deprecated, superseded) and date, both required; supersedes and supersededBy, lists of document ids |
runbook | runbooks/ | severity (sev1 to sev4) and lastTested, a date; both optional |
incident | incidents/ | date, severity and status (open, mitigated, resolved), required; rootCause and solution, one sentence each, written once it is resolved |
The ADR states match EventCatalog’s. What a document applies to is about:,
the same field every document has, so a decision about a service is listed on
that service’s page:
---title: Publish payment events on Kafkaowner: payments-platformstatus: accepteddate: 2026-03-14supersedes: - payments-service/adr/0001-rabbitmq-for-payment-eventsabout: - service/payments-service - event/payment/payment.completed---An incident keeps its short answers in frontmatter, where the index shows them, and the full write-up in the body:
---title: Duplicate payment.completed after a consumer rebalanceowner: payments-platformdate: 2026-09-14severity: sev2status: resolvedrootCause: The settle step published before committing its offset.solution: Publishing now happens after the commit.about: - service/payments-service---ac docs check validates each document against the base contract and its
kind’s schema together, names the kind in each failure, and checks that every
supersedes and supersededBy id resolves, as it does for related.
The conventions file
Section titled “The conventions file”An organisation changes the kinds with one file,
contracts/autocatalog.conventions.json, which also says
where a team keeps its events, published and found the way the
frontmatter contract is: at the aggregation root, or the one an enrolled
repository (usually the handbook) publishes. Two repositories publishing
different copies is an error that names both files.
ac docs init-conventions # write the built-in kinds, then edit them in place{ "kinds": { "runbook": false, "adr": { "folder": "decisions" }, "rfc": { "label": "Proposals", "folder": "rfcs", "path": "proposals", "description": "Changes proposed across teams, and what came of them.", "columns": ["status", "date", "reviewers", "owner"], "statusField": "status", "dateField": "date", "schema": { "type": "object", "required": ["status", "date"], "properties": { "status": { "enum": ["draft", "review", "accepted", "withdrawn"] }, "date": { "type": "string", "format": "date" }, "reviewers": { "type": "array", "items": { "type": "string" } } } } } }}Each kind takes:
label: the plural the index page, sidebar and home page use.folder: where its documents sit underdocs/.path: its index page,/<path>/. Defaults to the label in lower case.description: the sentence under the index page’s title.schema: JSON Schema for the kind’s own frontmatter, checked together with the base contract. A base contract closed withadditionalProperties: falsestill accepts the kind’s fields.columns: the frontmatter fields the index table shows after the title.statusFieldanddateField: the field shown as a badge and filtered on, and the field the index sorts newest first by.
false turns a built-in kind off. An object for a built-in kind replaces only
the fields it names, and a field it names replaces the built-in one whole,
schema included. A kind’s path must not be an enrolled repository’s name or
a page the site already publishes, such as catalog; the build says which kind
to change.
Check every pull request
Section titled “Check every pull request”A document that breaks the contract leaves its repository’s documents off the site, so catch it in the pull request. This is the whole workflow:
name: catalogon: pull_requestjobs: check: runs-on: ubuntu-latest steps: - uses: jagreehal/autocatalog/actions/check@v1It checks the documents, source contracts and events the repository publishes,
and skips what it does not have. On a pull request it also compares against the
base branch, so a change that deletes a contract fails even when nothing is left
to check. Document and contract failures mark the files they name. A repository with
rules passes allow-code-execution: true, which runs them, so leave it off for
pull requests from forks. On another CI, run
npx autocatalog check --base-ref origin/main.
The contract is yours
Section titled “The contract is yours”Frontmatter is validated against a JSON Schema the organisation owns, found at
contracts/docs-frontmatter.json without a flag. Where the root is a directory
of cloned repositories, as ac site --topic builds from, it is the one an
enrolled repository publishes there, usually the handbook:
ac docs init-schema # write the default contract, then edit it in placeac docs check # frontmatter, cross-repo links, deleted-but-linked docsac docs sync-schema # has this repository's copy drifted from the canonical one?ac docs manifest # publish contracts/docs-manifest.jsonAdd a field to that schema and it validates in CI, reaches the page, and can be enforced from a Python repository that has never installed any of this — because the contract is JSON Schema, not a plugin API.
A repository committing its own copy of the contract is how CI works without a
sibling checkout, and ac docs sync-schema is what keeps the copy honest: the
copy records where it came from in $id, the standard keyword for exactly that,
and drift becomes a failing command rather than a surprise.
What the site adds, and what it refuses to
Section titled “What the site adds, and what it refuses to”The site is Starlight with a plugin. Search,
keyboard navigation, dark mode, translations and accessibility stay Starlight’s —
they are not ours to get wrong. The plugin changes where the documents come
from and what the site can derive from the enrolled repositories. Conventional
events/ and Agent-system directories join the same Repository identity
automatically.
/catalog is the human inventory: every Document, Event, Service, Agent, Tool,
Model, Workflow, Policy, Eval, Domain, and Repository with its provenance and
connections. /catalog.json publishes the same information as
autocatalog/graph-v1. Rich event and agent analysis stays in the deeper domain
shells; this page is the shared navigation layer.
That contract is committed as JSON Schema and shipped with autocatalog-core
at contracts/graph-v1.schema.json, for the same reason the frontmatter
contract is: a rule that only exists as a TypeScript type cannot be enforced by
a repository that does not run TypeScript.
A repository whose own catalog will not compile is quarantined, not fatal.
Its documents, events or agents are left out, the build warns, and /catalog names the
repository and the reason. A document that breaks the frontmatter contract
leaves out every document in its repository, so its pages and the index never
disagree. Refusing to publish something partial still holds —
it holds in the repository that owns the mistake, where ac docs check and
ac validate fail the merge. One team must not be able to take down the index
every other team reads.
It also publishes two things for agents:
- a raw markdown twin at every page’s URL plus
.md llms.txt, an index of the whole site, linking those twins
Both are generated from the same documents the pages render, so neither can drift from what a reader sees.
Using it for your own docs
Section titled “Using it for your own docs”pnpm add -D autocatalog-docs-astroimport starlight from '@astrojs/starlight';import autocatalogDocs from 'autocatalog-docs-astro';
export default defineConfig({ integrations: [ starlight({ title: 'Acme docs', plugins: [autocatalogDocs({ root: '..' })], }), ],});root: '..' is the aggregating shape: a docs site in its own repository, reading
the sibling repositories checked out beside it. Point root at a directory that
contains docs/ instead — with repo naming it — to publish one repository’s
own documentation, which is what this site does.
Repositories do not have to sit one level down. Every folder below root with
its own docs/, events/, contracts/ or autocatalog.consumes.json publishes
under its folder name, so the packages of a monorepo publish the way separate
checkouts do: acme-platform/services/orders/docs/runbook.md is /orders/runbook.
A package’s owners come from the nearest CODEOWNERS above it. Two folders with
the same name cannot both publish: the one nearer the root keeps the name, and
the site names the other and leaves it out. test/, tests/, fixtures/,
examples/, experiments/, starters/ and templates/ folders are never
searched, and a docs/ or events/ folder that holds packages, such as a docs
site’s source, is read as packages.
Leave the sidebar out and it is generated from what is on disk, one group per repository. Configure a sidebar and yours is kept, which is the right call once a site has enough pages to need sections.