Most model-drafted docs pages fail on nouns, not on sentences. The prose reads fine, the code samples compile, and then a reader discovers that the page calls the same object a "workspace", a "project space", and a "tenant" in three consecutive paragraphs. Grammar review never catches this, because each sentence is locally correct. The failure lives at the vocabulary layer, and that layer belongs to a human.
This post describes a workflow I use for that layer: a human-owned glossary file, a draft contract that forces the model to flag concepts it cannot name, and a small lexical checker that fails a pull request when a draft renames something it was told to leave alone. The model drafts prose and examples; the human owns every definition, every allowed spelling, and every decision to admit a new term. The checker is a heuristic, not a semantic tool, and I will be explicit about where it stops being useful.
The failure mode that survives review
Renaming is cheap for a model and expensive for a reader. A model that has seen the word "tenant" in a thousand other projects will reach for it when the source page says "workspace", and nothing in the prompt forbids it unless the prompt carries a vocabulary contract. Human reviewers skim for wrong claims and broken links, so a synonym slips through in exactly the kind of low-attention pass that model-drafted pages receive. Two weeks later the search index contains both spellings, and support tickets use a third.
The fix is not a longer style guide. Style guides are prose, and prose is what the model is already good at generating around. The fix is a machine-readable list of terms with allowed forms, banned forms, and a named owner, so that both the drafter and the CI job read the same source of truth.
Step 1: Write the glossary as a human-owned lockfile
The glossary is a file in the repository, reviewed like code, and never written by the model. I keep it in TOML because it parses with the standard library and diffs cleanly in review.
# docs/glossary.toml — human-owned. The model may read this file; it may not edit it.
[terms.workspace]
definition = "A named container for projects, members, and billing."
allowed = ["workspace", "workspaces"]
banned = ["project space", "work area", "tenant"]
owner = "@avery"
[terms.run]
definition = "One execution of a workflow, identified by run_id."
allowed = ["run", "runs"]
banned = ["job", "task", "execution"]
owner = "@avery"
[terms.member]
definition = "A person with an account in a workspace."
allowed = ["member", "members"]
banned = ["user", "seat", "account holder"]
owner = "@sam"
Three fields carry the weight. definition is what a human signs off on, and it is the only place a concept is allowed to mean something. banned is the list of near-misses the model is most likely to produce, drawn from your own review history rather than from imagination. owner matters because a glossary with no owner decays into a second style guide that nobody reads.
Keep the first version small. Twelve to twenty terms covering the objects on your most-visited pages will catch more drift than an exhaustive list assembled in one sitting and never updated.
Step 2: Give the drafter a contract with an escape hatch
A model told "use the approved terms" will still guess when it meets a concept that has no entry. Give it a legal way to say "I do not know what to call this" and the guessing drops sharply. I put this contract in the prompt and also commit it as docs/draft-contract.md so a reviewer can see what the model was told.
You are drafting one documentation page. Read docs/glossary.toml first.
1. Use only the `allowed` spelling for each glossary term. Never use a `banned` variant.
2. If a concept has no glossary entry, do not invent a name. Insert the inline
marker [TERM_NEEDED] where the concept first appears.
3. At the end of the draft, emit a section titled OPEN TERM with one line per
marker: `[TERM_NEEDED] <phrase you would have used> — <why it is needed>`.
4. Do not modify docs/glossary.toml. Do not add definitions of your own.
5. Fenced code blocks must come from a file that exists in the repo; cite the path.
Rule 3 is the part people skip, and it is the part that makes the workflow economical. Instead of a draft full of confident wrong nouns, you get a draft with a short, bounded queue of naming decisions waiting for a human. Naming a concept is a design decision, and design decisions do not belong in a generated paragraph.
Step 3: Check the draft with a lexical gate
The checker does one thing: it fails when a banned variant appears outside a fenced code block. It runs in milliseconds, needs no API key, and produces line numbers a reviewer can jump to.
#!/usr/bin/env python3
"""tools/termcheck.py — fail a docs draft that renames human-owned terms."""
import pathlib
import re
import sys
import tomllib
GLOSSARY = pathlib.Path("docs/glossary.toml")
FENCE = re.compile(r"```
.*?
```", re.S)
def load_terms():
data = tomllib.loads(GLOSSARY.read_text(encoding="utf-8"))
return data["terms"]
def check(path, terms):
raw = path.read_text(encoding="utf-8")
text = FENCE.sub("", raw) # never police code samples
lowered = text.lower()
problems = []
for term, spec in terms.items():
for variant in spec.get("banned", []):
pattern = rf"\b{re.escape(variant.lower())}\b"
for match in re.finditer(pattern, lowered):
line = text.count("\n", 0, match.start()) + 1
problems.append(
f"{path}:{line}: banned variant {variant!r} for term {term!r}"
)
return problems
def main(argv):
terms = load_terms()
files = [pathlib.Path(p) for p in argv[1:]]
problems = [p for f in files for p in check(f, terms)]
for line in problems:
print(line)
print(f"termcheck: {len(problems)} violation(s) in {len(files)} file(s)")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
The fence-stripping line is not cosmetic. Without it, every CLI example containing the flag --user becomes a violation, and the team turns the check off within a week. Policing prose and policing code are different jobs, so keep them in different passes.
Run it against only the files a pull request touched, which keeps the signal local to the change:
git diff --name-only origin/main...HEAD -- 'docs/**/*.md' \
| xargs python3 tools/termcheck.py
Step 4: Run the loop where the draft lives
A draft-and-check loop is only cheap if the environment is disposable. I run the generation pass, the checker, and a Markdown link check inside a remote workspace rather than on my laptop, which is where MonkeyCode's free model access and free server option come into this workflow.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The relevant part is not the drafting itself; it is that the feedback loop is quick enough to run on every page instead of on a weekly batch. The model reads docs/glossary.toml and docs/draft-contract.md, writes one page plus an OPEN TERM list, and the checker decides whether the result reaches a human. I keep the glossary and the contract in the repository either way, so the workflow survives a change of tooling, and I treat availability as something to confirm for your own account rather than a permanent guarantee.
Review the OPEN TERM list before anything else, because it is the highest-value artifact in the output. Each entry is either a genuine gap in the glossary or a sign that the model misread the page, and both are worth two minutes of a human's attention. Accept an entry by adding a term to the lockfile with a definition and an owner, then discard the draft's temporary phrasing.
Step 5: Separate what the model drafts from what a human owns
The table below is the working boundary I use. It is deliberately conservative at the edges where a wrong noun becomes a wrong contract.
| Artifact | Model | Human |
|---|---|---|
| Page prose, transitions, headings | Drafts | Edits |
| Code samples | Copies from cited repo files | Confirms the file exists and runs |
| Glossary definitions | Never writes | Writes and signs off |
| Allowed and banned variants | Never writes | Writes after review |
| OPEN TERM entries | Proposes | Accepts or rejects |
| Page-level claims about behavior | Drafts with citations | Verifies against source |
| Removing a deprecated term | Never | Human, with a redirect plan |
Limitations and who should not use this
The checker is lexical. It catches the variants you listed and nothing else, so a model that invents an entirely new synonym for an unlisted concept passes cleanly. It also says nothing about whether a definition is correct, whether a page is complete, or whether two teams mean the same thing by the same word.
Do not adopt this if you have fewer than roughly ten stable domain terms, because the lockfile costs more to maintain than the drift it prevents. Do not adopt it for marketing or tutorial prose, where synonyms are a feature and the vocabulary is intentionally loose. Do not gate a merge on the banned list during the first week either, since the initial run will surface legitimate uses in quoted text and historical changelogs; fix those, then turn the check into a required status.
One optional extension is worth the effort once the base check is quiet: extract capitalized multiword phrases from the draft and report any that have no glossary entry.
CANDIDATE = re.compile(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2})\b")
Expect noise from sentence starts and product names, so treat the output as a review aid and never as a failing check. The moment a heuristic produces false failures, reviewers stop reading it.
If you try one thing from this post, make it the lockfile plus the OPEN TERM rule; the checker can come later, and the naming decisions will still land in front of a human instead of inside a generated paragraph.
Top comments (0)