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.

catalog-info.yaml
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.

catalog-info.yaml — one bundle, many components
# 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.

.github/workflows/docs.yml
- 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.

.github/workflows/docs.yml — channel per branch
      --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.

shell
yarn workspace backend add @brnby/plugin-colophon-backend@next
yarn workspace app add @brnby/plugin-colophon@next
packages/backend/src/index.ts
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.

packages/app/src/App.tsx — New Frontend System
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.

app-config.yaml
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/ layout
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.

frontmatter
---
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.

docs.yaml
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.

shell
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.