Getting started
Annotate, publish, configure
Three moving parts: a catalog annotation linking an entity to a bundle, a CI step that publishes the docs tree, and a block of app-config telling the backend where to read from.
Not installable yet. Colophon is in early development and no
package has been published to a registry. The commands below are the shape the
workflow takes, run against the packages in this repository — they are not yet
an npm install away.
01
Link the entity to a bundle
One annotation in catalog-info.yaml. The value is a bundle id —
lowercase, path-like, and usable verbatim as a storage key prefix.
metadata:
annotations:
brnby.io/colophon: github.com/brnby/payments-api
Monorepos: the #subpath form
When several components share one docs/ tree, scope each entity to
a subtree by appending # and a slug prefix. Each entity's docs tab
— and each agent's view of it — is filtered to that subtree, and a request for
a slug outside it returns not found.
# services/billing
brnby.io/colophon: github.com/brnby/platform#services/billing
# services/ledger
brnby.io/colophon: github.com/brnby/platform#services/ledger
Both shapes are supported: one shared bundle referenced by many entities, or one bundle per component published separately from the same repository.
02
Publish from CI
The publisher scans the directory, validates it, assembles a manifest, uploads the blobs that are not already stored, and writes the manifest last.
- name: Publish documentation
run: |
npx @brnby/colophon-cli publish ./docs \
--bundle-id "github.com/${{ github.repository }}" \
--channel latest \
--storage s3 \
--s3-bucket "$COLOPHON_BUCKET"
Add --backend-url and --token to register the
revision with Backstage in the same step, so the channel is repointed as soon
as the upload lands rather than at the next scheduled indexing run. The
publisher also ships as a container image, built to run as
docker run -v "$PWD:/work" against a repository it does not have
baked in.
Release branches map onto channels
The default channel is what a bare docs URL resolves to. Other channels stay
reachable through the version picker, and through
colophon:search with an explicit channel filter.
--channel ${{ github.ref_name == 'main' && 'latest' || github.ref_name }}
A pull request publishing to pr-42 never touches
latest, and the revision it creates is not chunked until something
points at it.
Verify before you push
colophon validate ./docs runs the same scan and validation as
publish without uploading anything, which makes it a good
pre-commit hook. Add --strict to turn advisory diagnostics —
chiefly a missing page description — into failures.
Retiring is the other half, and it is not covered here:
colophon delete-channel closes a per-pull-request preview,
colophon delete-bundle removes a decommissioned repository, and
colophon gc reclaims the storage neither of them touches. Every
command and every flag is in
the CLI reference.
03
Install and configure
Releases go out under the next dist-tag while the bundle
contract is still moving, so ask for it explicitly.
yarn workspace backend add @brnby/plugin-colophon-backend@next
yarn workspace app add @brnby/plugin-colophon@next
import { searchModuleColophonCollator } from '@brnby/plugin-colophon-backend';
backend.add(import('@brnby/plugin-colophon-backend'));
// Only if you want documentation in portal-wide search.
backend.add(searchModuleColophonCollator);
The collator is a named export of the package's main entry point rather than
an /alpha subpath, and backend.add takes a feature
or a promise of a module namespace — not a promise of a feature — so the
static import is the shape that type-checks.
import colophonPlugin from '@brnby/plugin-colophon';
const app = createApp({ features: [colophonPlugin] });
Every key lives under colophon in app-config.yaml.
The backend needs read-only access to the store; CI needs write.
colophon:
storage:
type: s3 # s3 | local
s3:
bucket: ${COLOPHON_BUCKET}
region: ${AWS_REGION}
# For local development, prefer:
# type: local
# local:
# directory: ./colophon-data
retention:
# Revisions a channel points at are never collected. Beyond those, this
# many recent revisions per channel are retained.
revisionsPerChannel: 10
chunking:
# Applied at index time, so changing these re-chunks on the next run
# without any repository re-running its CI.
splitDepths: [2, 3]
maxChars: 1500
minChars: 200
schedule:
# Re-reads the catalog for brnby.io/colophon annotations. Cheap — run it
# often, because until it does a newly annotated entity has no docs tab.
entityLinks:
frequency: { minutes: 10 }
timeout: { minutes: 5 }
initialDelay: { seconds: 15 }
# Projects documentation into Backstage Search. Expensive: it pages the
# whole corpus over HTTP, so it wants to run far less often.
searchIndex:
frequency: { minutes: 60 }
timeout: { minutes: 30 }
initialDelay: { seconds: 60 }
| Key | Required | Default | Notes |
|---|---|---|---|
storage.type |
no | local |
s3 or local |
storage.s3.bucket |
when s3 |
— | |
storage.s3.region |
no | AWS SDK default | |
storage.local.directory |
no | ./colophon-storage |
Development only |
retention.revisionsPerChannel |
no | 10 |
Channel-pointed revisions are never collected |
chunking.splitDepths |
no | [2, 3] |
Heading depths that start a chunk |
chunking.maxChars |
no | 1500 |
Soft ceiling; long sections split on paragraphs |
chunking.minChars |
no | 200 |
Shorter sections merge into the next sibling |
MCP exposure is configured in the MCP Actions Backend rather than under
colophon — see
the agent-facing page for that block and for
per-user OAuth. A complete example lives at
app-config.colophon.yaml in the repository.
The table above is the subset you need to get running. The backend ships a
config schema, so a key it does not declare is a startup error rather than a
setting that quietly does nothing — which makes the full list worth having:
the configuration reference
documents every key, with defaults, and covers appPath,
storage.s3.prefix, S3-compatible endpoints and permissions.
04
Writing docs that retrieve well
docs/
index.md required, the entry point
**/*.md nesting is fine
_assets/ images and attachments
docs.yaml optional: explicit nav and overrides
Navigation is derived from the directory tree unless docs.yaml
supplies an explicit nav. Keeping that file optional is
deliberate: requiring an explicit nav is the biggest onboarding tax in MkDocs.
---
title: Rotating database credentials
description: One sentence describing what this page answers.
type: how-to # tutorial | how-to | reference | explanation
tags: [database, security]
status: current # current | draft | deprecated
---
Only title is strictly required, and even that falls back to the
first H1. Two of the optional fields carry more weight than they look.
description is what an agent sees in a search
result before deciding whether to fetch the whole page. A weak description
means the agent fetches everything and burns its context.
type uses the
Diátaxis categories. It groups the UI, lets
agents filter for the right kind of page, and nudges you toward one purpose per
page — which is what makes sections self-contained.
docs.yaml
Every field is optional, and so is the file. It exists for what conventions cannot express: an ordering that is not alphabetical, a group header with no page of its own, pages held back from publication.
title: Payments docs # overrides the title from the root page
defaultType: reference # type for pages whose frontmatter omits one
exclude: # globs, relative to the docs root
- drafts/**
nav: # when present, wins over the directory tree
- title: Guides
children:
- page: guides/deploy.md
A misspelled key is an error rather than a silent no-op. Writing
navigation: instead of nav: used to mean a carefully
ordered nav simply never applied, on a green build.
Exclude patterns are relative to the docs root, so a leading slash matches
nothing: /drafts/** publishes the drafts,
drafts/** withholds them. A pattern matching no files is
reported, which is what makes that trap visible.
--strict promotes every advisory to an error: a missing
description, a page missing from the nav, a bundle with no landing page, an
exclude that matches nothing. Adopt it once your docs are clean — it is the
difference between a warning nobody reads and a gate.
Write sections that survive being read alone
Retrieval cuts pages into chunks at H2 and H3, and every section is retrieved without its neighbours.
- Say "OAuth 2.0 authentication", not "the authentication method above".
- Avoid pronouns that point at the previous section.
- Keep one complete thought under each heading.
A well-chunked corpus with plain full-text search beats a badly-chunked one with state-of-the-art embeddings, because a chunk that does not stand alone is useless no matter how well it was retrieved.
05
Working on Colophon itself
Node 22 or 24, and Yarn 4 via corepack.
corepack enable
yarn install
yarn verify # lint + arch lint + site lint + typecheck + test
Biome owns all formatting and general linting. ESLint is kept for exactly one
job — plugin:@backstage/recommended, seven monorepo
dependency-hygiene rules with no stylistic content — so the two tools cannot
conflict. Do not add stylistic rules to .eslintrc.js.
For a local end-to-end loop, publish to a directory instead of a bucket with
--storage local --local-dir ./colophon-data and point
colophon.storage.local.directory at the same path.