Skip to content

Generate and maintain documentation with Claude Code

Claude Code generates reference documentation (JSDoc and TSDoc comments, OpenAPI specifications, READMEs, architecture documents, and changelogs) by reading the source code rather than memory or an old wiki. Generation is the easy half. Hooks, deterministic gates such as TypeDoc validation and Redocly lint, and a headless drift check keep that documentation provably in step with the code.

This page is for developers who own a service’s docs and tech leads who want documentation to stop being a review burden. You know the situation: the README still mentions a command removed last sprint, openapi.yaml lists a field that was renamed, and new joiners ask the same questions because ARCHITECTURE.md describes the system from two refactors ago. Nobody updates docs by hand, so the fix has to be a workflow that regenerates them and a gate that fails when they drift.

What you’ll walk away with from documentation generation in Claude Code

Section titled “What you’ll walk away with from documentation generation in Claude Code”
  • A decision table: which docs Claude Code should generate, which it should only draft, and the check that proves each one.
  • Copy-paste prompts for TSDoc, OpenAPI, a README whose commands are proven, and a cited ARCHITECTURE.md.
  • A docs:check script and a Stop hook that refuse to let a session end with broken docs.
  • A headless drift report for pull requests that returns structured JSON your CI can fail on.

Before you start, put your documentation conventions in CLAUDE.md (see the Claude Code memory system), so every session writes docs the same way.

Which documentation should Claude Code write?

Section titled “Which documentation should Claude Code write?”

Claude Code is reliable wherever the answer is in the code. It is unreliable wherever the answer is in someone’s head. Decide per document type before you prompt:

DocumentSource of truthClaude Code’s roleWhat proves it is right
TSDoc / JSDoc on exported symbolsSignatures, types, thrown errorsGenerateTypeDoc validation fails on any undocumented export
OpenAPI specRoute handlers, validation schemas, middlewareGenerateRedocly lint plus a contract test that compares spec paths with registered routes
README setup sectionScripts that actually runGenerate, then executeCI runs the README commands from a clean checkout
ARCHITECTURE.mdDirectory layout, entry points, data flowGenerate with citationsA script checks that every cited path exists
Changeloggit log between tagsDraftThe release owner edits the wording and signs off
Decision records (“why we chose X”)People’s reasoningInterview and draft onlyThe decision owner writes or approves every rationale

The last row matters most. Asked “why does this module use a queue?”, Claude Code produces a plausible reason whether or not one exists. For rationale, use the interview workflow in architecture decisions agents can follow and let a human own the “why”.

  1. Add the gates to the repository first, so the first generated doc is already checked. For a TypeScript service:

    Terminal window
    npm install -D typedoc @redocly/cli

    Then add a docs:check script to package.json:

    {
    "scripts": {
    "docs:check": "typedoc --emit none --entryPointStrategy expand --entryPoints src --validation.notDocumented --treatValidationWarningsAsErrors && redocly lint docs/openapi.yaml && bash scripts/check-doc-citations.sh"
    }
    }

    --emit none validates without writing HTML. With --treatValidationWarningsAsErrors, TypeDoc 0.28 (checked with 0.28.20) exits non-zero when any exported symbol under src/ has no documentation.

  2. Run npm run docs:check once and save the failure list. That list is the backlog you hand to Claude Code.

  3. Paste the TSDoc prompt below into a Claude Code session, one directory at a time. A directory per session keeps each diff small enough to spot-check.

  4. Run npm run docs:check again. Green means every export is documented and the spec parses. It does not mean the prose is true, which is why the contract test and citation check below exist.

The “shorter than the function body” constraint is the cheapest fix for Claude Code’s main documentation habit, which is over-explaining. The @throws line is where generated docs beat hand-written ones: Claude Code follows the call chain and finds rethrown errors that a human author forgets.

Generate an OpenAPI spec that matches the running routes

Section titled “Generate an OpenAPI spec that matches the running routes”

A spec generated from handlers alone misses what middleware adds: auth rejections, rate-limit headers, and the shape your error handler gives every failure. Name the middleware in the prompt, and ask for a test that ties the spec to the router, because a lint pass only proves the YAML is valid OpenAPI.

From then on, the contract test is the oracle: a route added without a spec entry turns CI red, whether a person or an agent added it. For the rest of the API workflow, see building REST and GraphQL APIs with Claude Code.

A README fails new joiners in its commands, not its prose. Make Claude Code execute every command it writes, and make CI execute them again.

In CI, extract that one block and run it in a fresh container. The extractor reads only the first bash block inside the Quick start section and stops at the next ## heading, so a later section’s commands can never run by accident. A README that fails the job is a README that would have failed a new hire:

Terminal window
# CI step in a clean container: run the README's Quick start block, stop at the first failure.
awk 'c&&/^```/{exit} c{print;next} /^## /{if(f)exit; f=($0 ~ /^## Quick start/); next} f&&/^```bash/{c=1}' README.md > /tmp/quickstart.sh
test -s /tmp/quickstart.sh || { echo "README has no Quick start bash block"; exit 1; }
bash -euo pipefail /tmp/quickstart.sh

Generate an ARCHITECTURE.md you can check mechanically

Section titled “Generate an ARCHITECTURE.md you can check mechanically”

Ask for citations in a fixed format, then verify the citations with a script instead of reading the prose line by line.

Save this as scripts/check-doc-citations.sh. It fails when a cited path no longer exists, which is how architecture docs usually rot:

#!/usr/bin/env bash
# Fails if docs/ARCHITECTURE.md cites a path that does not exist.
set -euo pipefail
missing=0
while read -r path; do
[[ -e $path ]] || { echo "missing: $path"; missing=1; }
done < <(grep -oE '`[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+/?(:[0-9]+)?`|`[A-Za-z0-9_.-]+\.(json|ya?ml|toml|md|lock|ts|js|mjs|py|go|rs|sql)(:[0-9]+)?`' docs/ARCHITECTURE.md \
| tr -d '`' | sed -E 's/:[0-9]+$//' | sort -u)
exit $missing

The script checks every backticked token that contains a slash (under any directory, not only src/) and every root file with a common extension, such as package.json or pnpm-lock.yaml. A trailing :line is stripped before the check. The cost of that breadth: a backticked non-path such as request/response is reported as missing, so keep backticks for paths in this document.

The “Drift found” list is often the most useful output of the whole exercise. It is a free audit of every stale doc Claude Code read along the way. Understanding an unfamiliar codebase with Claude Code covers the same citation technique for exploration.

The changelog stays a draft by design. Its wording is a promise to users, so the release owner signs it off.

Keep documentation in sync while Claude Code edits

Section titled “Keep documentation in sync while Claude Code edits”

Generated docs rot the moment the next change lands. Two hooks close that gap inside the session. The mechanics of every hook event are in Claude Code hooks: guardrails, gates, and live context. Here are the two a documentation workflow needs.

A PostToolUse reminder that Claude Code actually sees. Plain echo output from a PostToolUse hook does not reach the model. To put the reminder in Claude Code’s context, return it as additionalContext:

#!/usr/bin/env bash
# .claude/hooks/doc-drift-reminder.sh (PostToolUse, matcher "Edit|Write")
set -uo pipefail
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*/src/routes/*|*/src/middleware/*)
jq -nc '{hookSpecificOutput: {hookEventName: "PostToolUse",
additionalContext: "You changed an API route or middleware. Before you finish, update docs/openapi.yaml if the request, response, auth or error contract changed."}}' ;;
esac
exit 0

A Stop hook that runs the gate. The reminder is advice; this hook is the enforcement. It blocks the first attempt to finish while docs:check fails and hands over to a human if the retry also fails:

#!/usr/bin/env bash
# .claude/hooks/docs-before-stop.sh (Stop)
set -uo pipefail
retrying=$(jq -r '.stop_hook_active')
[[ -z $(git status --porcelain) ]] && exit 0
if out=$(npm run --silent docs:check 2>&1); then exit 0; fi
if [[ $retrying == "true" ]]; then
jq -nc '{systemMessage: "docs:check still fails after one retry. Stopping for a human."}'
exit 0
fi
jq -nc --arg r "docs:check fails. Fix the docs, not the check. Last 30 lines:
$(tail -n 30 <<<"$out")" '{decision: "block", reason: $r}'

Register both in .claude/settings.json:

{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/doc-drift-reminder.sh", "args": [] }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/docs-before-stop.sh", "args": [], "timeout": 180 }] }
]
}
}

Run chmod +x .claude/hooks/*.sh, then open /hooks in a session to confirm both loaded.

Catch documentation drift in CI with headless Claude Code

Section titled “Catch documentation drift in CI with headless Claude Code”

Hooks cover sessions that use them. Changes also arrive from people, from other agents, and from sessions without your settings, so the pull request needs its own check. The job below is read-only: it asks Claude Code whether the diff made any documentation stale and returns a validated JSON verdict that the script, not the model, turns into a pass or fail.

Save it as .github/workflows/docs-drift.yml:

name: docs-drift
on: pull_request
permissions:
contents: read
jobs:
drift:
# Fork PRs get no secrets; skip them instead of failing.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # the diff needs the base branch's history
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm install -g @anthropic-ai/claude-code
- name: Report docs made stale by this diff
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
BASE_REF: ${{ github.base_ref }}
run: |
schema='{"type":"object","required":["stale"],"properties":{"stale":{"type":"array","items":{"type":"object","required":["doc","reason"],"properties":{"doc":{"type":"string"},"reason":{"type":"string"}}}}}}'
out=$(claude -p "Run git diff --name-only origin/$BASE_REF...HEAD, then read the
changed files and README.md, docs/openapi.yaml and docs/ARCHITECTURE.md. List
every statement in those docs that this diff made false, with the doc path and
the reason. Ignore wording and style. Return an empty list if nothing is stale." \
--bare --setting-sources "" --strict-mcp-config \
--permission-mode dontAsk --allowedTools "Bash(git diff *)" \
--output-format json --json-schema "$schema" --max-budget-usd 2)
jq -e '.subtype == "success"' <<<"$out" >/dev/null || { echo "drift check did not complete"; exit 1; }
if [ "$(jq '.structured_output.stale | length' <<<"$out")" -gt 0 ]; then
jq -r '.structured_output.stale[] | "\(.doc): \(.reason)"' <<<"$out"
exit 1
fi

Every flag was checked against claude --help 2.1.283 (the latest channel, 2026-09-26). fetch-depth: 0 matters: the default shallow checkout has no origin/main, so git diff fails and the job reports nothing useful. --bare --setting-sources "" --strict-mcp-config stops the pull request’s own hooks, settings, and .mcp.json from running in a job that holds a secret. Bare mode also skips your subscription login, which is why the job uses ANTHROPIC_API_KEY. dontAsk denies every tool call that would otherwise prompt, so the job can read the checkout and run git diff but cannot use the Edit or Write tools. The if: line skips pull requests from forks, which GitHub runs without secrets; do not switch to pull_request_target to get the key into them, because that runs the fork’s code next to your secret. The CI wiring and trigger choices are in Claude Code automation and pipeline configuration with Claude Code.

To regenerate docs after a merge, run the same kind of job on main with a narrow write grant: --allowedTools "Edit(./docs/**)" "Edit(./README.md)". Then have the script run npm run docs:check and upload the changes as a patch artifact; a separate job with contents: write and pull-requests: write, which runs no agent, applies the patch and opens the pull request. Keep persist-credentials: false in the agent’s job so no write token sits where the agent runs. Never let either job push to main.

On pull_request, GitHub runs docs-drift.yml from the pull request itself, so a pull request can edit or delete the check. Put .github/ under CODEOWNERS and require code-owner review before you treat this job as a merge gate.

Split a large documentation backlog across subagents

Section titled “Split a large documentation backlog across subagents”

A repository-wide documentation pass fills the main context with file contents. Delegate each area to a subagent, so each one reads deeply and returns only a summary.

For reusable documentation specialists with their own tools and instructions, see custom subagents in Claude Code.

Which skills and MCP servers help with documentation?

Section titled “Which skills and MCP servers help with documentation?”

Reach for a skill when you want a repeatable procedure the agent loads on demand. Reach for an MCP server when the agent needs live data it cannot read from the repository.

  • doc-coauthoring (Anthropic, anthropics/skills) runs a three-stage workflow of context gathering, refinement and structure, and reader testing. It suits the documents Claude Code must not invent alone: design docs, proposals, and decision records.

    Terminal window
    npx skills add anthropics/skills --skill doc-coauthoring -a claude-code
  • Context7 (@upstash/context7-mcp) pulls version-specific third-party library docs into context. Use it when your docs describe an integration, so an example matches the library version in your lockfile rather than the one in the model’s training data.

    Terminal window
    claude mcp add --scope user context7 -- npx -y @upstash/context7-mcp

The catalogues with more options are documentation and writing skills and documentation MCP servers such as Context7.

How do you verify generated docs without reading every line?

Section titled “How do you verify generated docs without reading every line?”

Read only what no check can cover. Everything else has a gate, and the gate’s output goes into the pull request as evidence:

CheckCommandCatchesWho signs off
Export coveragenpm run docs:check (TypeDoc step)Undocumented or newly added exportsCI
Spec validitynpx @redocly/cli lint docs/openapi.yamlInvalid OpenAPI, broken $refsCI
Spec matches routertests/contract/openapi-routes.test.tsMissing or phantom endpointsCI
README worksQuick-start block run in a clean containerDead commands, missing prerequisitesCI
Architecture citationsscripts/check-doc-citations.shReferences to moved or deleted filesCI
Drift on this PRHeadless drift reportDocs made false by the diffCI; the PR author fixes
Rationale and changelog wordingNoneInvented reasons, misleading promisesModule owner, release owner

The human review shrinks to the last row: the “why” sections and the changelog. That is where a person’s judgment is irreplaceable and where a generated sentence can do real harm. For the full list of what an agent’s pull request should carry, see the evidence bundle.

The docs are long and say nothing. Claude Code restates the code in prose. Recovery: add “Keep each comment shorter than the function body” and “explain why and when to use it, not what each line does” to the prompt, and move the rule into CLAUDE.md so it applies in every session.

The OpenAPI spec lints clean but is wrong. Lint checks structure, not truth. The usual cause is middleware the prompt never mentioned. Recovery: name the middleware directory in the prompt, and let the contract test, not the lint, decide whether the spec matches the router.

Claude Code ignores the drift reminder. The hook prints with echo, and PostToolUse stdout does not reach the model. Recovery: return the message as hookSpecificOutput.additionalContext, as shown above, or exit 2 with the message on stderr.

The CI regeneration job succeeds and changes nothing. Every write was denied: -p sessions never prompt, and project permission rules may not apply in CI. Recovery: print .permission_denials from the JSON result and pass the exact Edit(...) rules with --allowedTools.

A decision record contains a confident, invented reason. The model filled a gap in the code with a plausible story. Recovery: tell Claude Code to write “reason not recorded” whenever the code, commit messages, or linked issues do not state the reason, then ask the decision owner to fill those gaps.

The stop hook loops or blocks forever. The check fails for a reason Claude Code cannot fix, such as a missing dev dependency. Recovery: the stop_hook_active branch above stops after one retry, and Claude Code itself ends the turn after eight consecutive stop-hook continuations (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP changes the cap). If it still loops, run npm run docs:check yourself and fix the environment before you rerun.

The drift job fails with unknown revision or reports an empty diff. The checkout is shallow, so the base branch is missing. Recovery: set fetch-depth: 0 on actions/checkout, as in the workflow above, and confirm that git diff --name-only origin/main...HEAD lists files in a local clone of the PR branch.

Where to go next with documentation in Claude Code

Section titled “Where to go next with documentation in Claude Code”