Docs Update
SkillWeb & browsingLets your agent update your documentation website after you finish an SDK or CLI change, proposing minimal fixes to stale pages.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Docs Update skill
About this capability
Updates the docs website after a change to the SDK or CLI. Finds the pages your change made out of date and proposes the smallest edit that fixes them. Use when invoking /qv-docs-update.
What this skill tells your AI
The instructions your AI receives, as published by tetherto/qvac in .agents/skills/qv-docs-update/SKILL.md and read by ahel’s review.
Whenever a developer finishes implementing a feature in the SDK or the CLI, they invoke this skill to update the docs website, adding or updating the content. That way, every new feature PR opens with its documentation already in place.
Route a source change to the documentation pages it invalidated. Propose the smallest patch that makes them correct again.
Everything you write is read by a developer building a local AI application on QVAC, an open-source ecosystem. docs/website is their developer portal: it teaches how to use the SDK and the CLI, never how the codebase works internally. Write for that reader in Phase 3 and Phase 5.
Run this skill only when the developer asks for it. The source API must be stable. Do not run it mid-implementation: developers iterate on an API several times before it settles, and prose written against a moving surface is the waste this skill exists to avoid.
What this skill reads and writes
There are three observed source packages: packages/sdk, packages/sdk-python, packages/cli. They are read-only.
Never edit anything under packages/**. If a source file is wrong, report it and stop. Fixing source is the developer's job.
The writable surface is defined in references/docs-scope.md. Read that file before writing anything. A write outside the allowlist aborts the run and reverts every patch already applied.
Pipeline objects
The skill builds four objects, in order. Each one appears as a block in the final report.
| Object | Question it answers | Built in |
|---|---|---|
SOURCE_CHANGE_SET | What changed in the code? | Phase 2 |
DOCS_IMPACT | What does that mean for a user? | Phase 3 |
DOCS_TARGETS | Which pages and sections became wrong, and why? | Phase 4 |
DOCS_PATCH | What is the smallest change that fixes them? | Phase 5 |
Cardinality:
1 SOURCE_CHANGE_SET -> 1 DOCS_IMPACT (always one, never split)
1 DOCS_IMPACT -> N pages (routing is a union of hits)
1 page -> N targets (distinct sections of that page)
1 target -> 1 reason + 1 patch
DOCS_IMPACT is always a single report. Never split it. Never estimate how many pages it will touch. The router produces the page grouping, deterministically.
You have exactly two jobs in this pipeline, and both are verifiable: describe the change (Phase 3), and judge a concrete page you have in front of you (Phase 4). Nothing here asks you how many independent impacts a change contains. That question has no ground truth, and nothing downstream needs the answer.
States
A state classifies the documentary impact of a change, and decides what the skill does next: proceed to routing, stop and report, or ask the developer a question.
There are eight. Six classify one source file. Two describe the whole run.
| State | Scope | Meaning |
|---|---|---|
NO_SOURCE_CHANGE | run | Nothing changed in the three packages against the merge base. |
NO_DOCS_IMPACT | file | Code changed. Nothing user-facing went stale. |
GENERATED_DOCS_ONLY | file | User-facing impact, fully covered by a generated surface. |
DOCS_UPDATE_REQUIRED | file | Editable prose must change. Proceed to routing. |
NEW_CAPABILITY_PAGE | file | New AI capability with no page. The skill creates it. |
NEW_MODELS_PAGE | file | New model-lifecycle topic with no page. The skill creates it. |
HUMAN_INPUT_REQUIRED | file | An ambiguity the repo does not resolve. Ask the developer. |
DONE | run | Every patch applied and validated. |
The two page-creation states are the same operation on different subtrees, and they are mutually exclusive. Separate them by what the symbol is about, not by where its source file sits. A symbol that performs inference — it takes a prompt, audio, or an image and returns a generated result — is an AI capability. A symbol that acquires, inspects, or prepares a model without performing inference is a model-lifecycle topic. completion and transcribe are the first. downloadAsset and assessModelFit are the second.
Assign the file-scoped states one source file at a time. If one file is unresolved, keep the patches already proposed for the files that routed cleanly.
Both scripts report a single run-level state field, and it is advisory. You own the state machine, not the scripts.
The collector decides only the two states it can prove alone, NO_SOURCE_CHANGE and NO_DOCS_IMPACT, and reports CONTINUE for everything else. CONTINUE is not one of the seven states: it means the script reached no verdict and Phase 3 must judge. The router reports its best guess from the routing signals it can see. Treat either field as a starting point, then refine per file as you work.
The final report carries one state in its header. Choose it this way. If any file is HUMAN_INPUT_REQUIRED, then report HUMAN_INPUT_REQUIRED, and list the resolved patches alongside the pending question. Else if every patch was applied and validated, then report DONE. Else report the state that every file shares.
Decision flow
This is every path through the skill, and every point where it stops. The phases below implement this flow.
Developer finishes the feature
│
▼
/qv-docs-update
│
▼
Phase 1 — resolve the merge base; collect committed, staged,
unstaged and untracked changes
│
├── nothing changed ──────────────► NO_SOURCE_CHANGE STOP
├── every changed file `internal` ─► NO_DOCS_IMPACT STOP
▼
Phase 1 — export diff, TSDoc diff, auxiliary context
│
▼
Phase 2 — build SOURCE_CHANGE_SET
│
▼
Phase 3 — classify against the impact policy
│
├── nothing user-facing ──────────► NO_DOCS_IMPACT STOP
├── a generated surface covers it ─► GENERATED_DOCS_ONLY STOP
├── is the behaviour supported? ──► HUMAN_INPUT_REQUIRED STOP
│ the repo does not say
▼
DOCS_UPDATE_REQUIRED
│
▼
Phase 4 — run the router
│
├── new_capability_symbols ───────► NEW_CAPABILITY_PAGE
│ symbol performs inference four append-only edits,
│ then Phase 6
│
├── new_capability_symbols ───────► NEW_MODELS_PAGE
│ symbol is model lifecycle three append-only edits,
│ then Phase 6
│
├── unrouted and user-facing ─────► HUMAN_INPUT_REQUIRED
│ for that source only;
│ routed pages continue
▼
Phase 4 — filter each candidate; write one reason for each
│
▼
Phase 5 — write the patch; present the diff
│
├── developer rejects ────────────► revise, present again
▼
Apply to disk
│
▼
Phase 6 — five gates
│
├── any gate fails ───────────────► report the error, leave
│ the patches on disk,
│ do not declare DONE STOP
▼
DONE
Phase 1 — Collect the source change
- Inputs: the current git working tree.
- Outputs:
/tmp/qv-docs-scs.json(written by the script), plus three records you gather by hand: export diff, TSDoc diff, auxiliary context. - Expected result: every changed path in the three packages is accounted for and carries a bucket, across all four git states. A file missed here is invisible to every later phase.
Start with the script. It resolves the merge base, collects changes across all four git states (committed on the branch, staged, unstaged, untracked), and classifies every path into a bucket.
- Run the collector.
bash .agents/skills/qv-docs-update/scripts/collect-source-changes.sh > /tmp/qv-docs-scs.json
The output has this shape:
{
"state": "CONTINUE",
"base": { "ref": "tether/main", "sha": "3f2a91c…", "short": "3f2a91c", "via": "url" },
"strong_evidence": true,
"buckets": ["api", "examples"],
"file_count": 2,
"files": [{ "path": "packages/sdk/src/client/api/completion-stream.ts", "bucket": "api", "status": "M" }]
}
Each file carries a bucket. The bucket decides which router runs in Phase 4.
| Bucket | Paths |
|---|---|
examples | packages/{sdk,sdk-python}/examples/** |
api | packages/sdk/src/client/api/** |
client-other | packages/sdk/src/client/** outside api/ |
surface | barrels, src/types/**, src/schemas/** |
cli-command | packages/cli/src/{bundle-sdk,serve,configure,openai,doctor,verify}/** |
cli-infra | packages/cli/src/cli/**, src/{config,errors,logger,index}.ts |
python-surface | packages/sdk-python/** outside examples/ |
area | packages/sdk/src/{logging,models,server,worker}/** |
internal | everything else |
The script decides two states on its own. It reports NO_SOURCE_CHANGE when nothing changed, and NO_DOCS_IMPACT when every changed file is internal. Every other run reports CONTINUE: changes exist, and their documentary impact is Phase 3's to judge.
- If
stateisNO_SOURCE_CHANGEorNO_DOCS_IMPACT, then stop and emit the no-update report. Else continue.
strong_evidence is true when any of examples, api, or cli-command was touched. Treat it as weight in Phase 3, not as a verdict. Do not put it in the report.
base.via records how the base was chosen: url when a remote points at tetherto/qvac, explicit when the developer passed --base, single-remote when neither applied and the clone has exactly one remote. Report the base with its via. A single-remote base plus an implausible file_count means the base is wrong, so stop and say so rather than routing hundreds of files.
The script cannot read the public export surface. Get it from the barrel, comparing the base against the working tree.
- If no changed file is in the
apiorsurfacebucket, then skip to step 5. Else read the barrel at the base and in the working tree, and record added, removed, and renamed exports.
git show <base-sha>:packages/sdk/src/client/api/index.ts
Compare it against the current packages/sdk/src/client/api/index.ts. The barrel is the authority on what is public.
- Read the diff of every file in the
apiandsurfacebuckets, then record each changed function signature and each changed TSDoc block.
git diff <base-sha> -- <file-path>
For an untracked file, read the file directly. There is no diff to read.
- Gather auxiliary context.
Collect three things: the branch commit messages (git log <base-sha>..HEAD --format=%s), the PR title and body (gh pr view --json title,body, only when a PR exists), and any changed test that exercises an affected symbol. A new test often states new behaviour more plainly than the diff does.
Phase 2 — Build SOURCE_CHANGE_SET
- Inputs: everything collected in Phase 1.
- Outputs: one
SOURCE_CHANGE_SETtext block. - Expected result: the block carries every fact the later phases need, so nothing downstream has to reopen the raw diff.
This object is what you read from here on. Do not go back to the raw diff after this phase.
- Write the
SOURCE_CHANGE_SETblock in this exact shape.
SOURCE_CHANGE_SET
Base: tether/main @ 3f2a91c (via url)
Packages: packages/sdk
Buckets:
- api: packages/sdk/src/client/api/completion-stream.ts
- examples: packages/sdk/examples/completion-events.ts
- surface: packages/sdk/src/types/generation.ts
Exports:
- changed: completion() — new optional parameter `maxTokens?: number`
- unchanged: all others
TSDoc:
- completion(): @param maxTokens added
Tests:
- packages/sdk/test/completion-max-tokens.test.ts (new)
Phase 3 — Build DOCS_IMPACT
- Inputs: the
SOURCE_CHANGE_SETblock, and references/docs-impact-policy.md. - Outputs: one
DOCS_IMPACTtext block, and a state. - Expected result: the block states the change in the user's terms, not the code's, and every later claim in a patch traces back to something written here.
The question here is not "did the code change?". Phase 1 already answered that. Ask two questions instead, and answer both.
First: did a claim the docs make stop being true, or become incomplete?
Second: with the docs exactly as they stand today, can a user use the feature that changed?
The two catch different failures. The first catches a page that went stale. The second catches a page that is still entirely correct and yet leaves the user unable to reach the new capability. A new optional parameter usually makes no existing sentence false, and still leaves the user with no way to learn that the parameter exists. If the answer to either question is bad, the change has documentary impact.
-
Write the
DOCS_IMPACTblock in this exact shape.
DOCS_IMPACT
User-facing change:
completion() accepts an optional maxTokens parameter.
Public surface affected:
- completion()
- GenerateTextOptions.maxTokens
Behaviour:
- maxTokens caps the total tokens generated in the response.
- Omitted, current behaviour is unchanged.
Generated coverage:
- The API summary will list completion() with the new signature.
- The API summary will NOT describe what maxTokens means. It omits parameter descriptions by design.
Documentary implication:
maxTokens is essential to controlling output, so it belongs on the capability page, per the policy on essential parameters.
- If the repo does not settle whether the changed behaviour is public and supported, then emit
HUMAN_INPUT_REQUIRED, put that exact question to the developer, and stop.
This is the impact ambiguity, not the routing one. It appears when an observable behaviour changed and no barrel export, no TSDoc, no test and no page says whether that behaviour is part of the contract or an accident of the implementation. Documenting an accident is worse than documenting nothing, because the next change silently breaks a promise the docs made. Ask instead of inferring. Phase 4 raises the same state for a different reason: there the behaviour is known and the page is not.
- If the state is
NO_DOCS_IMPACTorGENERATED_DOCS_ONLY, then stop and emit the no-update report. Else continue.
When you claim GENERATED_DOCS_ONLY, name the covering surface in the report. An unnamed claim is not checkable.
Phase 4 — Route to DOCS_TARGETS
- Inputs:
/tmp/qv-docs-scs.json, and theDOCS_IMPACTblock. - Outputs: one
DOCS_TARGETSblock, grouped by page, with a written reason per candidate. - Expected result: every candidate the router produced is either kept with a reason, or dismissed with a reason. None is silently dropped.
The router reads the JSON from Phase 1, not the SOURCE_CHANGE_SET.
- Run the router.
bun run .agents/skills/qv-docs-update/scripts/route-docs-targets.ts --input /tmp/qv-docs-scs.json
The output has this shape:
{
"state": "DOCS_UPDATE_REQUIRED",
"base": { "ref": "tether/main", "sha": "3f2a91c…", "short": "3f2a91c", "via": "url" },
"r3_used": false,
"high_page_count": false,
"new_capability_symbols": [],
"pages": [
{
"page": "ai-capabilities/text-generation.mdx",
"targets": [
{
"page": "ai-capabilities/text-generation.mdx",
"section": "Examples › Usage",
"sectionLevel": 3,
"via": "R1",
"source": "packages/sdk/examples/completion-events.ts",
"evidence": "file=<rootDir>/packages/sdk/examples/completion-events.ts",
"line": 185
}
]
}
],
"unrouted": [{ "source": "…", "bucket": "…", "status": "M", "reason": "…" }],
"discarded": [{ "page": "…", "source": "…", "via": "R4", "reason": "…" }]
}
Read the fields as follows:
pages[].targets[]are the candidates. Each one is a page section to judge in step 3.evidenceis the authored binding the router matched. It is a fact about the repo, not a guess.lineis where that binding sits in the page. Use it to find the section fast.sectionisnullon a page-level hit: every R3 hit, and thecli/http-server/**subtree of R4. The router bound the page, not a section, because the map and the subtree rule name pages only.unrouted[]are source files no router could place. Handle them in step 5.discarded[]are hits that fell outside the allowlist or hit a path declared undocumented. Copy them into the report. Never re-add them.new_capability_symbols[]are new exported symbols inclient/api/with no page. Each one meansNEW_CAPABILITY_PAGE.r3_usedistrueonly when every hit came from the area map. It is run-level, so a run with one R1 hit and one R3 hit reportsfalse. To weigh a single candidate, read itsviafield instead: R3 is the declared fallback, so treat an R3 candidate with more suspicion than an R1, R2 or R4 hit.high_page_countistruewhen the router produced more than four pages. It is counted before your filtering, so recount after step 3.
There are four routers, and their hits are unioned. R1, R2 and R4 are exact: each resolves a binding that already exists in the content. R3 is the declared fallback and labels itself as such.
| Router | Binding it resolves | Buckets it covers |
|---|---|---|
| R1 | the literal file=<rootDir>/… directive in a fence | examples |
| R2 | the /reference/api#<symbol> anchor | api, export diff |
| R4 | the ### `qvac <command>` heading | cli-command |
| R3 | references/routing-map.yaml | everything else |
Four router behaviours affect how you read the output:
- R1 has no false-positive mode. No page inlines a full example. A TS example also routes the page that shows its transpiled
dist/**.jscounterpart. - R2 takes symbols from the barrel, not the filename.
completion-stream.tsexportscompletion, so the anchor is#completionand never#completionstream.rag.tsexports nine functions andtranscribe.tsexports two. Use the same rule when you write a link in a patch. - R2 also has a secondary pass for a symbol linked somewhere other than its anchor, reported as the weaker binding.
text-generation.mdxlinks the text`batchCompletion()`to the batch-processing page instead of to its API anchor, and carries a paragraph on how that function sharesparallelslots. That page is a real target even though the anchor is absent. A symbol mentioned in prose with no link at all is routed by nothing. - R4 also routes the narrative sections that describe a command outside
## Reference, and forserve/it adds the wholecli/http-server/**subtree. - R3 runs per file, not per run. It picks up only the files the exact routers could not resolve. A commit that touches an example and a config module gets an R1 hit for the example, and R3 still runs for the config module. One file's exact hit never suppresses the fallback for another file.
A pages: [] entry in the routing map is a positive declaration that a path is intentionally not documented. It is why a file can be unrouted without becoming a question.
- Read each candidate section in the page it belongs to.
Judge the page itself, not the diff. Read the section with DOCS_IMPACT in hand.
If section is null, then read the whole page and choose the section yourself, then state that choice and its justification in the candidate's Reason. This is the one place where you pick a target the router did not name, so make the choice auditable rather than silent. If no section on the page fits, dismiss the candidate — do not invent a section for it.
- For each candidate, answer this question in writing: did this section become incorrect, incomplete, misleading, or materially insufficient after the change described in
DOCS_IMPACT?
Keep the candidate if the answer is yes. Dismiss it if the answer is no. Either way, write the reason. A page that routed by accident has no answer to "which claim here went stale?", and that is what removes it.
The Reason field is the contract for the patch. Phase 5 is bound to it, so write it precisely.
- Write the
DOCS_TARGETSblock, grouped by page, in this exact shape.
DOCS_TARGETS — 2 pages, 3 targets
ai-capabilities/text-generation.mdx
1. Section: "Features"
Via: R2 (completion -> /reference/api#completion)
Reason: the list of generation controls is complete today and would
become incomplete by omitting maxTokens.
Action: list maxTokens among the controls.
2. Section: "Examples › Usage"
Via: R1 (packages/sdk/examples/completion-events.ts)
Reason: the introductory sentence describes what the script does, and the
script now demonstrates the new parameter.
Action: update the introductory sentence.
configuration/index.mdx
3. Section: "Reference"
Via: R3 (packages/sdk/src/client/config-loader/**)
Reason: R3 bound the page and named no section; "Reference" is the only
section that enumerates config keys, and its key table lacks the
new one.
Action: add the key, its accepted values, and its default.
Dismissed:
- ai-capabilities/batch-processing.mdx
Reason: links completion() only by comparison; no claim went stale.
There is no page limit, and multi-page is a normal result.
The team rule "1 change == 1 scope" describes the scope of the change in the source. It says nothing about how many pages document that change, and the two are different quantities. A new CLI command that reads a new config key is perfectly scoped, and it needs both cli/index.mdx and the configuration page. Do not drop a target to keep the page count down. The gate against wide routing is the written reason, not a count.
- If more than four pages survived filtering, then emit this note and continue. It never blocks.
Routing produced 6 pages after filtering. That is allowed but uncommon. Worth
checking whether routing went wide (candidates that should have been dismissed)
or the source change mixes scopes. The patches stand.
- For each entry in
new_capability_symbols[], classify the symbol by the rule in States, then run the matching subprocedure, below. If the symbol performs inference, runNEW_CAPABILITY_PAGE. Else runNEW_MODELS_PAGE.
Both subprocedures also apply to a symbol the router could not place because no page links its anchor yet. R2 binds a page by the /reference/api#<symbol> link it already contains, so a public symbol that no prose page mentions routes to nothing and reaches unrouted[] whether or not it is new in this run. A symbol in that position, exported from the barrel and listed in the API summary, needs the page the routers found missing rather than a question to the developer. Confirm the absence before creating the page: search docs/website/content/docs/ for the symbol name and for its anchor, and treat a hit in reference/** or release-notes/** as no coverage, since both are generated.
- For each entry in
unrouted[]that is user-facing and carries nonewSymbol, emitHUMAN_INPUT_REQUIREDfor that source and ask which page covers the topic.
new_capability_symbols[] is derived from unrouted[], so a new symbol appears in both lists. Step 6 already handled it. Asking about it here would create the page and then ask which page covers the topic.
The answer becomes a new routing-map.yaml entry. That is how the map grows. A source file that is not user-facing needs no question: ignore it.
Phase 5 — Write and apply DOCS_PATCH
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 612
- Forks
- 112
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
qv-docs-update- Source
- github.com/tetherto/qvac