Doc-Update Bot
The doc-update bot (qubital-doc-bot) drafts documentation pull requests automatically whenever a code PR merges to main in the backend or frontend repository. It reads the merge, routes to the affected doc pages, calls Claude to write the edits, and opens a draft PR on this docs repo for human review. This page documents the whole system as built — the workflows in both repositories, the routing logic, the guardrails, the supporting CI checks, and the configuration it depends on.
System overview
The system has two independent halves, both running in the code repository (backend or frontend) and both writing to the docs repository through a single GitHub App identity:
- Content half —
doc-bot.yaml. Fires after a PR merges tomain, drafts the documentation edits, and opens a draft doc PR. This is the part that writes documentation. - Routing-metadata half —
detect-package-renames.yaml. Fires on pushes tomain, detects renamed Go package directories, and opens a draft PR updating thecovers:frontmatter so the content half keeps routing correctly. It never writes documentation content.
The full flow, from a code merge to a published doc page:
A third workflow, human-edited-guard.yaml, lives in the docs repository and protects reviewer edits on bot branches (see Guardrails).
End-to-end flow (developer's view)
From the perspective of a developer shipping a code change:
-
Open a code PR and fill
## Docs to update. This is what the bot routes from, so be precise. There are three valid ways to fill it:- List the full path to each affected page — relative to
docs/, one per line, each ending in.md/.mdx(thedocs/prefix is optional). A page that doesn't exist yet is fine — the bot drafts it as new. - Write
None(a short reason may follow:None — owned by the DevOps task) when the change needs no docs. This also tells the bot not to run thecovers:cross-check. - Leave it blank to let the bot route the change itself via
covers:matching on your changed files. Filling the field is not required — blank simply means "bot, you pick the pages." (It can only reach pages that already exist and are covered; to create a new page, name its path as above.)
## Docs to updatereference/backend/features/token-manager.mdreference/backend/api/websocket/signaling.mdWhat to avoid, and exactly why it bites:
- A folder (
reference/backend/features/) is the worst input. It is read as a declared path, dropped because it is not a page, and — because you did declare something — the bot will not fall back tocovers:. You get no draft and nocovers:help (worse than leaving it blank). Use the file path. - A bare filename (
token-manager.md) routes to the docs root (docs/token-manager.md) — almost always the wrong place. - A topic keyword (
authentication) has no path, so it is treated like a blank field →covers:fallback. - A wrong-but-valid path (a real
.md, but the wrong page) is the subtle trap: the bot drafts that wrong page, and the routing report flags the page you missed — but only after a review cycle. When unsure, look the path up indocs/.
- List the full path to each affected page — relative to
-
Merge your code PR. The bot fires after the merge — it never gates or delays it, and a bot failure is only ever a comment on your PR, never a failed check.
-
The bot routes and drafts. Using the workflow on
main, it reads your diff and the pages you listed, and asks Claude to draft only the edits the diff justifies — it won't rewrite still-accurate sections. If you listed pages, it also runscovers:in parallel as an audit; if you left the field blank,covers:is what picks the pages. -
If the
covers:audit finds an affected page you didn't list, the bot posts a routing note on your code PR naming it. It's report-only — nothing extra is drafted, so you decide whether it matters. -
A draft doc PR appears on the docs repo: labelled
bot-generated, your PR's approvers set as reviewers, and a Routing footer showing what it drafted, anycovers:matches it audited-but-didn't-draft, and any emptycovers:it auto-filled from your changed packages. -
Review the draft. Edit it directly on the branch if needed — your edit labels it
human-editedand locks the bot out of that branch, so a later re-run can't clobber your changes. Then approve and merge, or close to discard. -
On merge to docs
main, Cloudflare Pages redeploys the site. Each page's "Edited by … on …" bar is derived from git history at build time (the squash-merge author and date) — there is no stamping step and no second commit.
If step 3 routed to the wrong page, or the note in step 4 flags one you actually need:
- Re-run the bot — Actions → "docs: draft doc PR on merge" → Run workflow → set
pr_numberand the correcteddocs_to_update(or leave it blank to fall back tocovers:). Because the branch name is derived from the PR title, the re-run updates the same draft PR instead of opening a duplicate. See Manual re-run.
Trigger and scope
doc-bot.yaml triggers on pull_request: types: [closed], plus a manual workflow_dispatch. It runs only when the PR actually merged into main, or when dispatched manually:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main')
The merge has already happened by the time the workflow runs. The bot is never a merge gate — it cannot block or delay a code merge, and a failure is reported as a comment on the code PR, never as a failed required check.
The job checks out the code repo only to put the bot script on disk; it does not need the Go or TypeScript source. PR metadata, the changed-file list, and the unified diff all come from the GitHub API.
Manual re-run
A merge run reads the PR title, body, and author from the event payload. A workflow_dispatch run has no such payload, so the script fetches the PR object from the API by number instead. The dispatch takes two inputs:
| Input | Required | Meaning |
|---|---|---|
pr_number | yes | The merged code PR to re-process. |
docs_to_update | no | Page path(s), one per line, to route to. Blank routes via covers:. None skips. |
This is the recovery path when the original run routed to the wrong page (or missed one). Because the branch name derives from the PR title, a re-run lands on the same bot branch and updates the same draft doc PR rather than opening a duplicate. Re-running with a corrected docs_to_update, or with it left blank to fall back to covers:, regenerates the draft against the right page. If a declared path does not exist yet, the bot drafts it as a new page (see Creating new pages) — provided the path is declared; covers: only ever matches pages that already exist.
Routing
The bot decides which doc pages to touch from the ## Docs to update section of the code PR, with covers: prefix matching as a second input. The covers: index is built in memory on every run: the bot clones the docs repo, parses the frontmatter of every .md/.mdx page, and maps each covers: package path to the pages that declare it. There is no pre-built index file and no external service.
Primary: developer-declared pages
Every code PR template carries a ## Docs to update section.
The ## Docs to update section is the bot's primary routing signal. The routing report catches most mistakes — a wrong or undeclared page is flagged on the code PR — but that costs a review cycle, and a wrong entry sends the bot to the wrong page first. Get it right up front:
- Use a full file path, one per line, ending in
.md/.mdx(e.g.reference/backend/features/token-manager.md) — not a folder, a bare filename, or a topic keyword. The table below shows exactly how each is handled; a folder is the worst case (you get no draft and nocovers:fallback). - No docs needed? Write
None(a short reason may follow:None — owned by the DevOps task).Nonealso switches off thecovers:cross-check. - The real trap is a wrong-but-valid path: it drafts the wrong page. The routing report flags the page you missed — but only after a review cycle.
The bot parses it:
| Section content | What the bot does |
|---|---|
One or more full file paths (relative to docs/, each ending in .md/.mdx), one per line | Route to exactly those pages. A path to a page that does not exist yet is drafted as a new page. |
A directory (e.g. reference/backend/features/) | Parsed as a declared path, then dropped because it is not a page. Since something was declared, covers: is not used as a fallback → the bot reports "no matching pages" and opens nothing. Worse than blank. |
Begins with None (case-insensitive; a trailing reason is allowed, e.g. None — owned by another task) | Open no doc PR; comment on the code PR and stop. covers: is not consulted. |
Blank, missing, or text with no usable path (e.g. a topic keyword like authentication) | Treated as absent → covers: becomes the router: changed files are matched against covers:, matched pages are drafted, or a "no matching pages" comment is posted if none match. |
This field is optional — it never blocks the merge. The pr-validate.yaml check (see Supporting CI checks) only surfaces the field's value in the CI log; an empty field is valid and routes via covers:, exactly as the last row shows.
covers: — router or audit
covers: prefix matching runs on every routed run (everything except None). A changed file matches a page when its path equals a covers: entry or sits beneath it. What the bot does with the matches depends on whether pages were declared:
- No pages declared (blank field):
covers:is the router. The union of matched pages is the candidate set; if nothing matches, the bot comments on the code PR and opens nothing. - Pages declared: the declared list is authoritative and is the only thing drafted.
covers:runs as an audit — any page it matches that the developer did not declare is reported, never drafted (see The routing report).
This is deliberate. The declared list expresses developer intent and stays in control of what gets written; the covers: audit is a second opinion that surfaces a page the developer may have missed, without overriding them and without adding review noise. The trade is that a missed page is flagged for a human, not auto-fixed — see Failure handling for the recovery path.
See Frontmatter Schema for the covers: field rules.
The routing report
So the routing decision is never silent, the bot reports it in two places:
- On the doc PR — every draft PR body ends with a Routing footer: which pages were drafted, and (in the declared case) any
covers:matches that were not drafted. - On the code PR — when the developer declared pages but
covers:matched a page they did not list, the bot posts a routing note comment naming the missed page(s), with instructions to re-run the bot against the correct path (see Manual re-run).
Because review only ever sees what is in a PR, the code-PR note is what makes an omitted page visible: a reviewer cannot reject a page that was never drafted, but they can act on a note saying one was matched and skipped.
Calling Claude
The bot sends two parts to the Anthropic API:
- System prompt —
claude-skills/doc-update-skill/SKILL.mdfrom the docs repo, sent unchanged. This is the bot's full instruction set: classification rubric, writing conventions, off-limits directories, frontmatter rules, and worked examples. - User turn — the assembled PR context: title, body, author, approvers, all commit messages, the changed-file list, the filtered unified diff (see below), and the full Markdown of every matched page.
The diff is the bot's only ground truth about the code change. SKILL.md instructs Claude never to document behaviour it cannot see in the diff.
Diff filtering
Before the diff is sent to Claude, a filterDiff step strips file sections whose paths cannot affect hand-written documentation. Dropped categories include test files (_test.go, *.test.ts, *.spec.tsx, …), lockfiles (go.sum, package-lock.json, pnpm-lock.yaml, yarn\.lock), mock files and directories, generated Go files (.pb.go, .gen.go, .generated.*), vendored code, and snapshot directories. If the filtered diff still exceeds 60,000 characters, it is first reordered (see below) and then truncated at that limit.
Filtering serves two purposes: it reduces input tokens (and therefore cost), and it ensures that the 60,000-character budget is consumed by code changes that can actually affect documentation rather than by noise that cannot.
When the filtered diff is still over the 60,000-character cap, a prioritizeDiffByPackages step moves the file sections under the routed pages' covers: packages to the front before truncation. Nothing is dropped — the reordering only changes which hunks survive the cut, so the code change the routed pages actually document stays inside the budget instead of being lost past the cutoff. When the diff already fits under the cap, ordering is unchanged.
Prompt caching
Both the system prompt and the first user turn are sent with cache_control: { type: "ephemeral" }. This instructs Anthropic's API to cache that prefix so that turns 2–N of a multi-tool-call loop, and back-to-back runs within the cache TTL, read the heavy PR-context prefix at roughly one-tenth of the normal input-token cost instead of re-paying full price on every request.
Model selection
The bot defaults to claude-sonnet-4-6. The model is overridable via the DOC_BOT_MODEL repository variable — setting it switches models without a code change. The default was chosen because Sonnet handles these structured, SKILL.md-driven edits at a significantly lower cost than Opus while producing equivalent output quality for this task.
Output cap and truncation guard
Each API call is capped at 16,000 output tokens (MAX_TOKENS), kept low so the non-streaming request stays within its HTTP timeout. If Claude hits that cap mid-output, the response is truncated and any page it was writing would be incomplete. The bot guards against this: a max_tokens stop reason aborts the run with a clear error instead of committing a partial page to the draft PR. If this ever fires in practice, the fix is to stream the call — which lifts the timeout constraint that pins the cap — not to silently raise the limit.
Each turn also logs its token usage (input, output, and cache read/write) for cost observability.
Tool use
Claude communicates exclusively through four tools (function calling), never free-text prose:
| Tool | Purpose |
|---|---|
write_page(path, content) | Create or update a page with the complete file content. |
skip_page(path, reason) | Record a candidate page deliberately left unchanged. |
needs_attention(item) | Flag an item requiring human judgment. |
finalize(branch_name, pr_body) | Called last; supplies the branch name and the PR body. |
The script collects the tool calls, applies every write_page to the cloned docs tree, and uses finalize for the branch and PR body. Structured tool output removes the need to parse Claude's prose — there is no regex on free text, and a missing finalize call is treated as an incomplete run and retried.
What the bot produces
When at least one page changes, the bot pushes a branch and opens a draft PR on the docs repo.
Branch name follows a fixed formula, validated and sanitised by the script:
bot/<type>-<slug>-from-<repo>-pr-<number>
<type>— the Conventional Commits type from the PR title (feat,fix, …), orchoreif absent.<slug>— the PR title lowercased, spaces to hyphens, non-alphanumerics stripped, truncated to 40 characters.<repo>—backendorfrontend.<number>— the originating PR number.
For example, backend PR #42 feat(auth): add OAuth2 login with WorkOS produces bot/feat-add-oauth2-login-with-workos-from-backend-pr-42.
The draft PR:
- Opens against
mainof the docs repo as a draft. - Carries the
bot-generatedlabel. - Assigns the originating code PR's approvers (up to three, most recent first) as reviewers.
- Has a body in a fixed structure — pages updated, pages created, pages skipped, needs-human-attention — ending with the footer
Generated by qubital-doc-bot · review carefully · edit freely · close to discard. - Carries a bot-appended Routing footer recording how the pages were chosen, including any
covers:matches that were audited but not drafted (see The routing report).
If a draft PR already exists for the computed branch, the bot updates that PR's body instead of failing.
Creating new pages
The bot drafts a new page when a declared path in ## Docs to update does not yet exist and the diff introduces a feature or package that warrants one. It writes the full file — frontmatter plus body — and adds the page to its parent index.md inventory. What it will not do is invent a page nobody declared: covers: matching only ever resolves to pages that already exist, so it can never create a page, and the bot does not propose speculative stubs for unmatched changes (it comments and stops instead).
Because a new feature's PR touches that feature's package, the workflow fills covers: for the new page from the change itself — deterministically, in the script, not by asking Claude. After Claude drafts the page (which leaves covers: []), the workflow looks at the changed file paths under the recognised package roots (internal/, pkg/, src/), ignores test files, and picks the package directory with the most changed files (e.g. a PR adding several files under internal/features/recording/… → covers: [internal/features/recording]; paths under internal/features/ keep three segments, other roots keep two). It writes that value into the page's frontmatter and records it in the draft PR's Routing footer (🔎 Auto-filled covers:) for the reviewer to confirm. The same applies to an existing page it edits whose covers: is empty — filling an empty field is strictly better than leaving it, since an empty covers: routes nothing.
The one thing the bot never does is touch a non-empty covers:: an established page's routing metadata is owned by humans and detect-package-renames.yaml (see Guardrails). Populating an empty field from the diff is safe and reviewed; silently rewriting a curated one is not.
Guardrails
The bot is constrained at several layers so it cannot silently damage the docs.
Fields it sets and never sets. The only frontmatter fields are title, description, and covers: — the bot sets the first two and never invents others. The "Edited by … on …" bar is derived from git history at build time (the squash-merge author and date), so there is nothing to stamp and no last_verified / verified_by field. For covers:, the bot never modifies a non-empty value — an established page's routing metadata is owned by humans and detect-package-renames.yaml. It does populate an empty covers: (on a page it creates or edits) from the changed files, choosing the single primary package directory, and records the value in the draft PR's Routing footer for the reviewer to confirm (see Creating new pages).
Directories it never edits. The bot may only write hand-authored documentation. Some directories are off-limits because a separate tool generates their contents — the bot's edits would be overwritten and ownership of the file would blur. Others are off-limits because they are human-owned by policy. SKILL.md lists them; when a code change affects content that belongs in one of these, the bot records it under "Needs human attention" instead of editing it.
| Path | Why it is off-limits |
|---|---|
docs/reference/backend/api/rest/ | REST reference, generated from the backend's OpenAPI spec by a separate tool — not hand-written. |
docs/reference/backend/pkgsite/ | Go package reference, generated from source (godoc) — not hand-written. |
docs/reference/frontend/components/ | Component reference, generated from the frontend source (TypeDoc) — not hand-written. |
docs/changelog/ | Generated from commit history. |
docs/meta/ | The authoritative writing conventions the bot itself reads — human-owned. |
docs/adr/ | Architecture decision records — written by humans only. |
docs/incoming/ | Temporary staging area for unsorted content. |
claude-skills/ | The bot's own instruction files — it must not rewrite itself. |
The three generated reference subtrees are reserved placeholders today: the generators that will fill them are planned, not yet live, and the bot stays out either way. Note that docs/reference/backend/api/websocket/ under the same api/ parent is hand-written and editable — only the rest/ and pkgsite/ subtrees are generated.
The human-edited guard. When a reviewer edits a bot draft directly on a bot/** branch, their push triggers human-edited-guard.yaml in the docs repo. That workflow applies the human-edited label to the open PR. Before the bot pushes to an existing bot branch, it checks for that label and skips the branch entirely if present — so a re-run of the bot never overwrites human edits. The guard fires only for non-App pushes (github.event.sender.type != 'Bot') and exits silently if there is no open PR for the branch.
covers: routing metadata
covers: is the secondary-signal index, and it only works if it stays accurate as the codebase evolves. detect-package-renames.yaml keeps it current.
On every push to main in the code repo, it diffs against the parent commit with git diff -M90 (git must be at least 90% confident a change is a rename, not a delete-plus-add). It groups file renames into directory renames within internal/ and pkg/ only — the paths that can appear in covers: — and confirms the old directory has no remaining files in HEAD, ruling out single-file moves. For each confirmed rename it opens a draft PR on the docs repo replacing the old path with the new one in every affected covers: field.
It deliberately does not handle two cases, both of which require human judgment and are flagged rather than automated:
- Package deletion — the doc page may need to be archived, merged, or removed.
- New packages — the content half creates the new page and sets
covers:at that time.
Failure handling
The script wraps the entire run in a three-attempt retry loop with a 30-second interval. Attempts one and two are silent. On final failure it posts a single comment on the code PR tagging the author with the error message and a link to the Actions run, then exits with code 1. There is no workflow-level retry. Because the workflow only ever runs after a merge, a failure never blocks anything.
Routing mistakes are recovered by a human, not auto-fixed. If the developer declared the wrong page, the declared page gets a draft (which the reviewer rejects) while the correct page is never drafted; if they declared a valid-but-unrelated page, the covers: audit flags the page they missed via the routing note. In both cases the fix is a manual re-run with the corrected docs_to_update (or blank, to fall back to covers:), or a direct edit. The bot deliberately does not silently redraft a page the developer chose not to declare — it surfaces the discrepancy and leaves the decision to a human.
Supporting CI checks
Several CI checks across both repositories keep the bot's inputs and outputs reliable. The first three run on code PRs; the last two run on docs PRs.
| Check | Repo | Role |
|---|---|---|
quality: PR template check | code | Informational only — surfaces the ## Docs to update value in the CI log (paths / None / empty → covers:). Never blocks: the field is optional. |
quality: commitlint — conventional commits | code | Enforces Conventional Commits via commitsar, giving the bot machine-readable type/scope/description context. |
quality: PR gate doc-bot job | code | Runs the bot's unit and contract tests (node --test): routing logic, branch formula, and retry behaviour, plus contract checks that the Anthropic SDK's response shape still matches what the script parses. |
docs: frontmatter validation | docs | Validates required frontmatter fields and covers: syntax on docs PRs (skips the generated reference subtrees — rest/, pkgsite/, components/). |
docs: Docusaurus build check | docs | Builds the site and catches broken links and bad MDX before Cloudflare Pages deploys. |
Each blocking check only blocks merges once it is added as a required status check in branch protection.
Configuration
The bot authenticates as the qubital-doc-bot GitHub App for every cross-repo operation. The App token is minted at runtime by actions/create-github-app-token and scoped to the account that owns both repositories, so a single token reads the code repo and reads/writes the docs repo. No personal access tokens are used anywhere.
It reads these secrets and variables in the code repository:
| Name | Type | Contains |
|---|---|---|
DOC_BOT_APP_ID | Variable | GitHub App numeric ID |
DOC_BOT_APP_PRIVATE_KEY | Secret | App private key (PEM) |
DOC_BOT_ANTHROPIC_API_KEY | Secret | Anthropic API key for Claude calls |
DOCS_REPO | Variable | Target docs repo in owner/repo form |
DOC_BOT_MODEL | Variable | (Optional) Claude model ID override; the script defaults to claude-sonnet-4-6 when unset |
For the App identity and credential rotation, see Doc-Bot Credentials & Rotation.
The bot script is a single Node.js ESM file at .github/scripts/doc-bot.mjs. The backend repository is pure Go and has no package.json or node_modules — the workflow installs the three runtime dependencies (@anthropic-ai/sdk, @octokit/rest, js-yaml) inline with npm install --no-save on the CI runner. Node is only a runtime for this one script; nothing is committed to the Go repo.
Design notes
The implementation favours a few small choices, each driven by the workflow's own logic:
- Tool use over prose parsing. Claude returns structured tool calls, so the script never parses free text and a malformed response fails cleanly into a retry.
- An inline
covers:index, not a served file. The bot builds the routing index from frontmatter at runtime. There is nofrontmatter-index.jsonartifact and no external service to keep in sync — the docs repo is the single source of truth. - Developer intent decides;
covers:audits. An explicit## Docs to updatelist controls what is drafted.covers:routes only when the field is blank; when pages are declared it runs as a second-opinion audit that flags a missed page (report-only) rather than overriding the developer or auto-drafting extra pages. covers:holds exact package paths, no globs. Exact directory paths are simple to prefix-match, cheap to validate, and mechanically maintainable on rename.- A GitHub App, not PATs. One App identity, short-lived owner-scoped tokens, and no long-lived personal credentials shared across repositories.