Agents That Update Docs
A doc-bot that opens PRs instead of pushing to main, constrained to mechanical ground by the surgical-edit contract, is the last automation layer before the human semantic boundary.
The Doc-Bot Pattern
Documentation drift that is not caught by drift tests (the drift-detection lesson) and not prevented by generators (the generated-references lesson) can often be addressed by a third automation layer: a doc-bot that monitors merges, identifies affected documentation, and opens PRs with mechanical updates.
The doc-bot is the A4 rung of the automation ladder: instead of a human remembering to update a doc after a merge, the bot does it automatically. But unlike a generator that directly produces output, the doc-bot works through the PR layer — every change it proposes requires a human to review and merge. This is not an optional safety measure; it is the foundational constraint that keeps the bot on mechanical ground.
The pipeline has five stages. The trigger is a merge to main — the GitHub Actions workflow fires on every push to the default branch. The diff scan extracts which files changed and by how much. The registry lookup maps changed source files to affected doc IDs using the DocRecord registry (covered in depth in the doc-registry lesson, but used here to illustrate the routing). The patch draft stage generates mechanical update instructions — counts, links, generated sections, nothing more. The PR stage opens the draft as a reviewable change with a provenance footer.
What does not happen: the bot never pushes to main. It never modifies narrative prose. It never makes architectural claims. It never decides that a section could be "improved" beyond what the explicit instructions specify.
The Surgical-Edit Contract
The doc-bot's behavior is bounded by what its prompts allow. An unconstrained prompt is the most dangerous input you can give a doc-bot. The model's default behavior, when asked to "update the README to reflect recent changes," is to make the README better — which means it will rewrite prose, remove sections it considers redundant, add sections it infers are missing, and generally do far more than you asked.
Every prompt the doc-bot sends to a language model must follow the surgical-edit contract.
The contract has four required parts. The scope declaration opens the prompt: "Fix only these items:" This signals to the model that it has a bounded task, not an open-ended improvement mandate. The item list follows — numbered, with file and line anchors where possible: "1. Update lesson count in line 4 from 48 to 52. 2. Replace broken link at line 23." The hard-stop line closes the prompt: "Do not change anything else." The provenance footer is appended to every bot-generated section: <!-- generated-by: docs-bot | source: commit <sha> | date: 2026-06-06 -->.
Without the scope declaration, the model assumes it is helping broadly. Without the item list, it interprets "recent changes" loosely. Without the hard-stop line, it treats "anything else" as within scope. The absence of any of these three is enough to produce a bot-generated change that overwrites working prose, removes deliberate content, or introduces fabricated context.
Mechanical Ground vs. Semantic Territory
The doc-bot should stay on mechanical ground: changes that can be verified programmatically without reading the meaning of the prose.
Mechanical changes the bot can safely make: updating a count from 48 to 52 when a new lesson is added, replacing a broken link with a resolved one, regenerating a key-files table section, updating a version string from 1.3.0 to 1.4.0, correcting a file path that was renamed.
Semantic territory the bot must not enter: explaining why an architectural decision was made, describing the behavior of a new feature, summarizing what a refactor changed, updating a "gotcha" section that requires understanding the system to write correctly. These are drafts for human review, not auto-merge candidates.
The safety model for this boundary is covered in full in the doc-bot-safety lesson. That lesson introduces the third anchor story in this track — a doc-updating bot that confidently documented a config option that had never existed, producing content that was plausible, well-written, and wrong, with fresh timestamps that signaled trust. For now, the rule is simple: if the correctness of the update requires reading the meaning of the content, it is semantic territory and requires human review before merge.
Building a Doc-Bot
A doc-bot wired into GitHub Actions follows this structure:
// scripts/doc-bot.ts
import { execSync } from 'child_process'
// Step 1: get the diff from the merge
const diff = execSync('git diff HEAD~1 HEAD --name-only').toString().trim().split('\n')
// Step 2: find affected doc IDs from the registry
const affectedDocIds = findAffectedDocIds(diff, DOMAIN_MAP)
// Step 3: for each affected doc, draft a mechanical patch
const patches = affectedDocIds.map((docId) => {
const record = REGISTRY.find((r) => r.doc_id === docId)
if (!record) return null
return draftPatch(record, diff)
}).filter(Boolean)
// Step 4: write patches to temp files and open a PR
// (Never: execSync('git push origin main'))
patches.forEach((patch) => {
applyPatch(patch)
})
execSync('gh pr create --title "docs: post-merge mechanical update" --body "' + formatPRBody(patches) + '"')
The draftPatch function uses the surgical-edit contract for any LLM-assisted drafting. For purely mechanical changes (count updates, link replacements), no LLM call is needed — the update is computed directly from the diff.
The GitHub Actions workflow:
on:
push:
branches: [main]
jobs:
doc-bot:
# Skip when the actor is the bot itself — prevents the bot-triggers-bot loop
if: github.actor != 'docs-bot'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # need HEAD and HEAD~1 for the diff
- run: npx ts-node scripts/doc-bot.ts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Note fetch-depth: 2 — without it, git diff HEAD~1 HEAD has nothing to compare against. This is a common source of doc-bot failure where the diff is empty and the bot does nothing.
The if: github.actor != 'docs-bot' line is not optional once the bot's own PRs can merge mechanically (the doc-bot-safety lesson). The bot opens a PR; when that PR merges, the merge is itself a push to main, which re-triggers this workflow, which scans the merge diff, opens another PR, and so on — the classic bot-triggers-bot feedback loop. Gating on the actor (or adding [skip ci] to the bot's merge commits, or a paths-ignore on the docs directory) breaks the cycle. A doc-bot without this guard works in testing and then loops the first time one of its PRs lands on main.
The BuildChallenge for this lesson implements the core doc-bot logic: diff parsing, registry-based routing, and mechanical patch drafting. The result is a set of DocPatch objects ready to be turned into a PR — the PR creation itself is left as an exercise in wiring the GitHub CLI.