The most useful split in model-assisted documentation is not prose versus code; it is facts versus sentences. When a model is allowed to restate a default value or a flag name, a wrong answer becomes indistinguishable from a right one at review time. The workflow below hands every checkable value to a collector script and every connective sentence to a drafting model. Facts are injected at render time from a checked-in artifact, so prose physically cannot originate them.
Two definitions keep the rest of this article honest. "Facts" means anything derivable from source: option names, defaults, requiredness, endpoint paths, enum members, and hard limits. "Sentences" means the explanation around those values, which is cheap to regenerate and expensive to get wrong in a subtle way.
What the model may draft, and what a human must own
The ownership table is the whole design compressed into rows. Anything with a mechanical source gets a key; anything requiring intent gets a name.
| Content | Owner | Reason |
|---|---|---|
| Option names, defaults, requiredness | collector script | re-derivable from source on every commit |
| Reference table row order | script | deterministic sort beats narrative flow |
| One-line definition per option | model may draft | low blast radius, fast to review |
| Why the option exists, when to avoid it | human | intent is not present in the code |
| Migration note after a default change | human | needs release context and deprecation intent |
| Fenced examples | human, pinned to a tested file | must actually execute |
| Warnings and security caveats | human | liability does not transfer to a generator |
Three rows are non-negotiable in my workflow: rationale, migration guidance, and caveats. A model may propose sentences for them, but a named human signs the merged version.
Step 1 — Derive a facts artifact from source
Start by making the checkable values a build output rather than a paragraph. The collector below is a template; swap the shell-out for your CLI's help output, your OpenAPI file, or your config schema loader.
# collect_facts.py — emit machine-derivable values as JSON
import json, subprocess, sys
def cli_options():
raw = subprocess.run(["mytool", "--help", "--format", "json"],
capture_output=True, text=True, check=True).stdout
opts = json.loads(raw)["options"]
return {o["name"].lstrip("-").replace("-", "_"): {
"flag": o["name"],
"default": str(o.get("default", "")),
"required": bool(o.get("required", False)),
} for o in sorted(opts, key=lambda o: o["name"])}
json.dump({"cli": cli_options()}, sys.stdout, indent=2, sort_keys=True)
Run it into a tracked file and review the diff like any other code change:
python collect_facts.py > facts/facts.json
python -m json.tool facts/facts.json | head -20
git diff --stat facts/facts.json
If the collected diff is empty, the docs reference cannot legally change. That property alone removes a large class of stale-value bugs before any model is involved.
Step 2 — Declare each page as fragments plus required keys
Keep a small manifest that names the page, its prose fragments, and the fact keys the page must cover. This manifest is what the guard test iterates over, so it stays short by design.
{"pages": [{"name": "cli-reference", "fragments": ["intro", "options"],
"required_keys": ["cli.output.flag", "cli.verbose.default"]}]}
Prose fragments then contain placeholders instead of values. Note that the fragment below never states a default; it only describes what the default means in practice.
### --{{fact:cli.output.flag}}
Writes machine-readable output when you set the flag to {{fact:cli.output.default}}.
Leave it unset for human-readable logs in interactive terminals.
### --{{fact:cli.verbose.flag}}
Default: {{fact:cli.verbose.default}}. Raise verbosity only when you need to
attribute latency to a specific stage.
Step 3 — Constrain the drafting model to placeholders only
Give the drafting model a contract rather than an example to imitate. My contract has three clauses, and the third one is what makes the pipeline fail loudly instead of quietly.
- Use a
{{fact:...}}token for every value; never type a flag name, default, or limit literally. - If a needed value has no key, emit
{{fact:MISSING:describe_what_you_need}}and stop that section. - Do not add warnings, migration advice, or version claims; those are human-authored slots.
The drafting step is where disposable model output helps most, because a rejected fragment costs a rerun and nothing else. I run those fragments through MonkeyCode's free model access, which the operator describes as available without payment, so regenerating a paragraph after a wording change is not a budget decision. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Step 4 — Render and guard the page
The renderer resolves placeholders and refuses to emit a page with unresolved keys. Substitution failures are collected first so you see every broken key in one run, not one per attempt.
# render_docs.py
import json, pathlib, re, sys
ROOT = pathlib.Path(__file__).resolve().parent
TOKEN = re.compile(r"\{\{fact:([a-z0-9_.\[\]-]+)\}\}")
def lookup(facts, dotted):
cur = facts
for part in dotted.split("."):
cur = cur[int(part)] if isinstance(cur, list) else cur[part]
return cur
def render(page, facts):
src = (ROOT / "prose" / f"{page['name']}.md").read_text()
missing = []
def sub(m):
try:
return str(lookup(facts, m.group(1)))
except (KeyError, IndexError, ValueError):
missing.append(m.group(1))
return f"<!--UNRESOLVED:{m.group(1)}-->"
out = TOKEN.sub(sub, src)
if missing:
raise SystemExit(f"{page['name']}: unresolved keys {sorted(set(missing))}")
return out
if __name__ == "__main__":
facts = json.loads((ROOT / "facts" / "facts.json").read_text())
pages = json.loads((ROOT / "pages.json").read_text())["pages"]
for page in pages:
pathlib.Path("dist").mkdir(exist_ok=True)
(pathlib.Path("dist") / f"{page['name']}.md").write_text(render(page, facts))
The second half of the artifact is the leak test. It strips placeholders, then asserts that no literal fact value survives in the prose, which is the mechanical version of "the model may not originate values".
# tests/test_docs_facts.py
import json, pathlib, re, pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
TOKEN = re.compile(r"\{\{fact:[^}]+\}\}")
ALLOW = {"stdout", "stderr"} # words that legitimately collide with fact values
def leaves(node):
if isinstance(node, dict):
for v in node.values():
yield from leaves(v)
elif isinstance(node, list):
for v in node:
yield from leaves(v)
else:
yield str(node)
@pytest.mark.parametrize("page", json.loads((ROOT / "pages.json").read_text())["pages"],
ids=lambda p: p["name"])
def test_no_literal_facts_in_prose(page):
values = {v for v in leaves(json.loads((ROOT / "facts" / "facts.json").read_text()))
if len(v) >= 3} - ALLOW
prose = "".join((ROOT / "prose" / f"{f}.md").read_text() for f in page["fragments"])
stripped = TOKEN.sub("", prose)
leaks = sorted(v for v in values if v in stripped)
assert not leaks, f"{page['name']}: literal facts leaked into prose: {leaks}"
Run the whole loop in one command so the guard, not the reviewer, catches drift:
python collect_facts.py > facts/facts.json && python render_docs.py && pytest tests/test_docs_facts.py -q
Step 5 — Keep a human gate with four questions
Automation shrinks the review surface but never removes it. Four questions cover almost every fragment that survives the leak test.
- Does any sentence assert behavior that has no corresponding fact key or tested example?
- Does the fragment give advice, name a version, or promise stability the project does not guarantee?
- Is every fenced example pinned to a file that runs in CI, rather than typed into prose?
- Who signs the rationale, migration, and caveat sentences for this page?
A fragment that fails question two is not edited; it is deleted and rewritten as a human-owned slot. That keeps the model's contribution to connective tissue and keeps accountability attached to a person.
Limitations and who should not use this
The approach only pays off where a machine-readable source exists for the facts. Design rationale, tutorials, postmortems, and architecture narratives have no collector, so the pipeline reduces to expensive prose storage. The leak test is also lexical: a paraphrased fact, such as "disabled unless you ask", passes while still stating a default. Short fact values below three characters collide with ordinary words often enough to need the allowlist shown above. Finally, the collector is code you now maintain, and a quietly broken collector produces a confidently wrong page.
Skip this workflow if your team lacks a single source of truth for option values, or if your docs are mostly persuasion rather than reference. It is also a poor fit for very small surfaces with four stable flags, where a plain Markdown file reviewed by hand is cheaper than a render step and a guard.
If you want to test the drafting half without changing your pipeline, run steps one and four locally first and keep the model out until the fact artifact is clean. The free model access and free server option are useful for repeatable fragment redrafts and for keeping the collect-render-test loop reachable from a shared environment, though neither substitutes for the ownership table. Start with the table, not the model, and the rest of the build follows.
Top comments (0)