Module 34 · 90 min

The Wiki, by Hand

You can build a knowledge base that an agent maintains, with a schema it must read first and a lint that catches what a person forgets.

Surface
a schema · a lint · no SDK at all
Workbench tag
module-34
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-30

Where does a project’s knowledge actually live? Which port the server listens on. Why that one flag exists and what broke the day it was added. The fix somebody found at two in the morning and pasted into a chat that scrolled away. The number that was true in March. Most of it lives in people’s heads, in closed conversations, and in the prompt of whichever agent happened to be running, and all three of those places forget. Module 02 showed why a fact in a model’s context drifts and dies with the session. This module builds the place where the app’s facts go to survive: a wiki, meaning a folder of small pages that link to each other, one page for each thing worth knowing.

You build it by hand, before any agent touches it, and the order matters. The pages themselves are cheap; agents will write most of them later. What agents cannot be trusted to invent is the rulebook, because an agent asked to keep notes will happily keep bad ones, duplicated, undated, confident, and wrong in ways nothing checks. So this module builds the container and its rules, writes exactly one page as the worked example, and hands the pen to the agents only in module 35, when everything they must obey is already true.

Nothing in this module imports the SDK. It is three markdown files, one worked page, and one lint script, and the whole thing would work identically for notes you keep for yourself.

The one idea: provenance beats timestamps

Here is the failure this module exists to prevent. A page in your knowledge base says the deploy takes four minutes, and its header says last-verified: 2026-08-30. Reassuring, but what does that date actually claim? That somebody timed a deploy that day? That an agent skimmed a config file? That a script bulk-stamped forty pages during a migration? The date cannot tell you, because a date only says when something happened. It never says what happened.

Provenance is the field that says what happened: I ran it and watched, I read it in a file, I reasoned it from other pages, a machine extracted it and nobody has looked, or the sources disagree. Pair that with the date and the header becomes a claim you can weigh. Leave it out and every page in the wiki wears the same confident face, and the pages most likely to be wrong, the machine-extracted ones, are indistinguishable from the ones a person checked an hour ago. That is the gist of the whole module, and everything below is machinery for keeping that pairing honest.

flowchart TD
raw["raw sources<br/>code, logs, command output<br/>outside the wiki, read only"] -->|ingest| wiki["knowledge-base/<br/>one concept per page,<br/>one fact in one place"]
wiki -->|lint| schema["_schema.md + kb-lint.mjs<br/>the contract, and its teeth"]
schema -->|governs| wiki
Three layers. Raw sources are never edited from here; the wiki is one concept per page; the schema and the lint are edited together or not at all.

The four files

chat-workbench/
├─ knowledge-base/
└─ tools/
knowledge-base/_schema.md

The contract, whole. An agent reads this before writing or editing any page; a person does too.

# The knowledge base's schema

Read this before writing or editing any page here. This file is the contract.
There is no separate README; the contract is the introduction. See
`docs/decisions/0100`.

## Three layers

1. **Raw sources.** Source files, SDK type declarations, container logs,
 command output, the decision records. They live outside this directory and
 are never edited from here. A page cites them; it does not replace them.
2. **The wiki.** This directory. One concept per page, one fact in one place.
 A fact that belongs on two pages belongs on one of them, and the other links
 to it with `[[page]]`.
3. **The schema.** This file, and `tools/kb-lint.mjs`, which is the only thing
 that checks it. The two are edited together. A rule stated here and not
 checked there is a wish.

A page is one Markdown file at the root of this directory, named for its
concept in lower-case with hyphens. Files whose name starts with `_` are
machinery, not pages, and no page rule applies to them.

## Frontmatter

Every page opens with one fenced block of flat `key: value` lines. No nesting,
no lists, no quoting. The parser accepts exactly this and nothing more, so a
page that parses is a page the schema allows.

Required on every page:

- `owner`, the one person or role answerable for the page being true. Not a
list. A page nobody owns is a page nobody re-checks.
- `volatility`, how fast the fact rots. One of `static`, `low`, `medium`,
`high`. Each carries a time to live, counted from `last-verified`:
`static` has none and never goes stale, `low` is 365 days, `medium` is 90,
`high` is 30. Past the tier's life the lint reports STALE.
See `docs/decisions/0103`.
- `last-verified`, `YYYY-MM-DD`, the day somebody or something last checked
the fact against its source. Not the day the file was edited.
- `verified-by`, who or what did that checking. A person's handle, or the
word `machine`, or the word `unverified`.
- `provenance`, what kind of checking it was. One of the five below. This is
the field that carries the signal; `last-verified` on its own is a date with
no claim attached to it. See `docs/decisions/0101`.

Optional:

- `expires`, `YYYY-MM-DD`. An absolute date the page already knows it stops
being true on: a pinned version, a deadline, a temporary workaround. Past it
the lint reports EXPIRED regardless of volatility.
- `todo`, one line of open work on this page. A worklist row, not a defect.
The lint reports TODO and still exits 0 for it.

Copy `_template.md` rather than typing the block from memory.

## The five provenance values

- `observed`, somebody ran the thing and read what came back. Evidence: the
command and the output it produced, quoted in the body.
- `read`, taken from a file in this repository. Evidence: the path, and the
line number if the file is long enough for one to help.
- `derived`, reasoned from other pages here rather than from a source
directly. Evidence: the `[[wikilinks]]` it rests on, which the body must
carry. A derived page is only as true as its links, and it says which.
- `extracted`, machine-extracted and unreviewed. A tool wrote it; no person
has read it against its source. Evidence: what extracted it. This value is
the honest one for a page that is probably right and has not been checked.
- `mixed`, the page carries claims of more than one kind. Evidence: each
claim labelled inline with the tier it belongs to. Prefer splitting the page.

## The pairing rules

These are rules, not guidance. The lint reads the pair, because either field
alone is easy to satisfy and says almost nothing.

1. **A machine-extracted page must not name a human.** If `provenance` is
 `extracted`, `verified-by` must be `machine`. A person's handle there
 asserts a review that did not happen, and it does so on exactly the pages
 most likely to be wrong. Finding: PROVENANCE.
2. **A page naming a human must say how they verified.** If `verified-by` is
 anything other than `machine` or `unverified`, the body carries a
 `## How verified` section stating what that person did. A name with no
 method is a signature on a blank page. Finding: PROVENANCE.
3. **A derived page cites its sources.** `provenance: derived` requires at
 least one `[[wikilink]]` in the body. Finding: PROVENANCE.
4. **Every tier except `extracted` needs a checker.** `verified-by:
unverified` on `observed`, `read`, `derived` or `mixed` is a contradiction:
 the tier claims evidence the field says nobody gathered. Finding:
 UNVERIFIED.

## Dates live in frontmatter

Do not write a date in the body of a page. `last-verified` and `expires` are
the only dates, and they are the only dates anything reads. A date in prose is
never re-checked, never reported, and drifts silently. Dates inside code
fences and inline code spans are quoted material and are left alone.
Finding: DATE-IN-PROSE. See `docs/decisions/0105`.

## Contradictions

When a new fact contradicts a page, do not overwrite the page and do not pick a
winner. Record both claims side by side under a `## Contradiction` heading,
each with its own provenance and its own source, and set `provenance: mixed`.
Then raise it with the `owner`.

Silently resolving a contradiction destroys the one piece of information that
matters, that two sources disagree, and leaves a page that reads as settled.
The disagreement is the finding.

## Cross-references

Link with `[[page]]`, the file name without its extension. Links are how a
reader gets from a fact to the facts around it, and they are the evidence a
`derived` page rests on. A link to a page that does not exist is a note that
the page should.

## The log

Every change to this directory gets one line in `_log.md`. Format is stated
there.
The whole knowledge base at tag module-34, every file in full. Three machinery files whose names start with an underscore, one real page, and the lint that checks them.

What a page looks like, line by line

Here is the header and the first half of app-port.md , the page the Build ladder will have you imitate. The tree above carries the file whole:

---
owner: server
volatility: low
last-verified: 2026-08-30
verified-by: machine
provenance: read
---

# The port the server listens on

The default is 3000. It is set in one place, `server/src/config.js`, and every
other appearance of the number in this repository is that default being
forwarded rather than a second decision.

`PORT` in the environment overrides it. The value must parse as a whole number
and be zero or greater, or the process throws at startup with `PORT` named in
the message; port 0 is legal and asks the operating system for a free port.

## Evidence

`provenance: read`. From `server/src/config.js`, in the object `loadConfig`
returns:

```js
port: readInt(env, "PORT", 3000, 0),
```

Two other files carry the number and neither decides it: `docker-compose.yml`
publishes `"3000:3000"` and sets `PORT=3000`, and `Dockerfile` sets `PORT=3000`
and declares `EXPOSE 3000`, which documents the port and publishes nothing.

Walk the header, because each line answers a question a future reader will have:

  • owner: server, one owner, never a list. A page two people own is a page nobody re-checks. The owner is who you go to when the page is doubted.
  • volatility: low, how fast this fact rots. Four tiers: static never goes stale (a definition, a thing that already happened), low gets a year, medium ninety days, high thirty. Past its tier’s life since last-verified, the lint calls the page STALE. A port default changes rarely, so low. The three numbers are declared in the records as round guesses, because nothing in this young repository has been observed rotting yet; they are yours to tune when yours has.
  • last-verified: 2026-08-30, the day the fact was checked against its source, not the day the file was edited. Fixing a typo does not move this date.
  • verified-by: machine, who checked. Three kinds of value: a person’s handle, the word machine, or the word unverified. machine is honest here, and it needs a sentence: the agent that wrote this page opened config.js and quoted the line, real evidence gathered by a real reader that happens not to be human. What machine may never do is impersonate a review that did not happen, which is the trap at the bottom of this page.
  • provenance: read, what kind of checking. The fact came out of a file in this repository, and the Evidence section quotes the path and the line. Notice what this page honestly does not claim: nobody started the server while writing it, so it states what the source decides, not what a socket bound. Choosing read rather than observed is exactly that distinction, made visible.

One body rule you cannot see by looking: no date appears in the prose. last-verified and expires in the frontmatter are the only dates, because they are the only dates anything reads. A date written into a sentence is never re-checked and drifts silently, module 02’s lesson relocated to disk, and the lint has a category for it.

The five provenance values

ValueWhat it claimsEvidence the schema demands
observedSomebody ran the thing and read what came backThe command and its output, quoted in the body
readTaken from a file in this repositoryThe path, and the line when it helps
derivedReasoned from other wiki pages, not from a sourceThe [[wikilinks]] it rests on, in the body
extractedA machine wrote it; no person has read it against its sourceWhat extracted it
mixedClaims of more than one kind, or a recorded disagreementEach claim labelled inline

extracted is the value the whole field exists for. A knowledge base that agents maintain will fill with machine-written pages, most of them right, none of them reviewed, and extracted is the honest label for that state: probably true, nobody has looked. The schema’s job is to keep that label from quietly upgrading itself.

Two more rules round out the contract, and both are the kind you feel the first time they save you. One fact lives in one place: the port page exists so no other page ever states the port, and anything needing it writes [[app-port]] instead, because two copies of a fact agree right up until one of them is updated. Contradictions are recorded, never resolved: when a new fact disagrees with a page, both claims go side by side under a ## Contradiction heading, each with its own provenance, the page becomes provenance: mixed, and the owner gets told. The schema’s own sentence carries the why: silently resolving a contradiction destroys the one piece of information that matters, that two sources disagree. The disagreement is the finding.

The lint, category by category

tools/kb-lint.mjs is the schema’s teeth: zero dependencies, an injectable clock so transcripts are reproducible, one plain sentence per finding, silence on a clean wiki. Seven categories, and the right response to each is different, which is why there are seven and not one:

FindingIt meansYou do
NAKEDRequired frontmatter is missing, or a value the schema does not defineCopy _template.md; the page cannot be judged until it parses
EXPIREDThe page named a date it would stop being true, and that date passedRe-verify or delete; the page predicted its own death
STALELonger since last-verified than the volatility tier allowsRe-check the fact against its source, then move the date
UNVERIFIEDThe tier claims evidence but verified-by says nobody gathered itGo check the page, or downgrade its provenance honestly
PROVENANCEThe fields contradict each other as a pairFix the header; the page is lying about its own history
DATE-IN-PROSEA date in the body, where nothing will ever re-check itMove it to frontmatter or delete it
TODOThe page names open workNothing urgent: a worklist row, and alone it exits 0

That last row is a design decision: a TODO does not fail the lint, because a worklist row that breaks the build gets deleted, not done (record 0104). Everything else exits nonzero, and the lint is wired into the repo’s own npm run check, so a dishonest wiki fails the same gate a broken test does.

PROVENANCE is the category the module is named for, and it fires on pairings rather than fields. From the real fixture run:

PROVENANCE machine-named-a-human.md: is `provenance: extracted`, machine-extracted
  and unreviewed, yet `verified-by: dana` names a person; nobody reviewed it,
  so no name may sit there
PROVENANCE human-without-method.md: names `verified-by: dana` but has no
  `## How verified` section saying what that person actually did

Read them together: the same rule pointing in opposite directions. A machine-written page may not borrow a person’s credibility, and a person’s name may not sit on a page without saying what the person did. Either field alone passes any field-at-a-time check; the lie lives only in the pair.

Run it

git checkout module-34
npm run kb                      # the lint, now part of the repo's own check

On the shipped wiki: nothing prints, exit 0. Point it at an empty folder and it prints one finding that is the module’s philosophy in a sentence:

NAKED _schema.md: does not exist, so this directory has no contract and no
page in it can be judged against one

Then run it where everything is wrong at once, with the clock pinned so your output matches this page:

KB_LINT_NOW=2026-08-30 node tools/kb-lint.mjs tools/kb-lint-fixtures

All seven categories fire, one sentence each, and the fixture names tell you what you are looking at: machine-named-a-human.md, stale.md, derived-without-links.md. The thirty tests behind the lint were validated by mutation, deleting each rule and watching its own test fail, so the categories you just saw firing are proven able to fire.

Making the model live in it

The wiki you built has a gap the lint cannot see: nothing tells an agent it exists. Claude Code does not open a folder because the folder is there. Start a session in this project tomorrow and the model will re-derive the port from config.js, state it in a reply, and let it scroll away, while app-port.md sits unread the whole time.

The fix is conditioning, and by that this page means something plain: instructions in the configuration the model reads at the start of every session, stating the working protocol in imperative sentences. Module 03 built that surface. A project CLAUDE.md conditions every session in this repository; a user-level CLAUDE.md or rules file makes the habit follow you into every project you open. Either way the protocol is short, and every sentence in it is there because the wiki dies a specific death without it:

  • Read the wiki’s index before starting work, and _schema.md before writing or editing any page. A model cannot cite a page it does not know exists, and it cannot obey a contract it has not read. The index is a list of what the wiki holds, one line per page. At two pages the folder listing serves; when the wiki outgrows a listing, the index becomes a page of its own.
  • When a round of work produces something durable, write it to the owning page in the same round. Durable means a decision and its why, a gotcha, a fix, a status change. The banned word is “later”, because later means after the session ends, and module 02 showed what the end of a session does to everything that lived only inside it. Knowledge batched for later is knowledge lost.
  • One fact, one page. The schema already demands this. The conditioning restates it because an agent deep in a task consults its standing instructions far more reliably than a file it has to remember to open.
  • Link related pages with [[wikilinks]]. A page nothing links to can be found only by someone who already knows its name. The double-bracket convention, and the whole shape of a vault of small linked pages, comes from Obsidian; its internal links documentation is where the syntax lives.
  • Convert relative dates to absolute. “Yesterday” is true for one day and wrong every day after, and nothing ever re-reads a sentence to fix it. 2026-08-30 does not rot.
  • Never write a secret into the wiki. The pages are plaintext markdown, injected into sessions, read by the lint, and in most projects committed. A key or password that lands in one is published, and no schema field can un-publish it.

None of this was invented for this page. It is the distilled version of an Obsidian-style vault configuration the course’s authors run daily in their own projects, cut down to the smallest set of sentences a first-timer can adopt whole. What the conditioning does not carry is the mechanics of a single write, the walk through choosing the owning page, the provenance value, and the log line. That walk is a skill’s job, and module 35 takes this further, into the capture skill that steps the model through the schema at the moment it writes.

Keeping it alive with hooks

A rules file is read by the model and weighed by the model, and module 09 opened on what that means: on a bad day the instruction loses the argument. The same module built the surface that does not argue: the hook, a program Claude Code runs at a fixed moment in a session. Two of those moments carry the whole wiki habit: the moment a session starts, and the moment Claude tries to finish a turn.

The start is SessionStart. On that event, a hook that exits 0 with plain text on stdout, the stream an ordinary print writes to, puts that text in front of Claude as context for the session. That is the entire mechanism. Print the index and every session opens already knowing what pages exist, whether or not anything reminded it to look.

One documented behaviour turns that wiring into a leak. A session “starts” five ways, and one of them is compaction, Claude Code replacing the conversation so far with a summary when the context window fills. SessionStart fires for compaction too, so a hook that injects on every firing re-injects the index after every compaction, and the context accumulates copies with no error anywhere. The guard is one check: read the source field in the event’s payload and stay silent unless it says startup, or name startup in the matcher and let the configuration do the checking. The event’s page walks all five sources and this exact trap.

The finish is Stop, and it is the capture protocol’s conscience. Stop runs when Claude finishes its turn, before control comes back to you, and it is one of the events module 09 listed where exit code 2 blocks: instead of ending the turn, Claude reads what the hook wrote and continues working on it. A capture hook uses that veto to hold the door once per turn and put one question in front of the model: did this round produce something durable, and if it did, is it on its owning page with a _log.md line?

Be precise about what the hook knows here, which is nothing. It is a program, and “durable” is a judgment, so the judgment stays with the model. The hook’s contribution is that the question gets asked every round instead of only on the rounds somebody remembers it. A sharper trigger can narrow when it asks, for instance blocking only when files changed and _log.md did not, but the division of labour holds: the hook forces the question, the model answers it, and the conditioning above is what makes the answer worth having.

Two facts from module 09 keep this hook from misbehaving. The event’s payload carries stop_hook_active, true whenever the current run is already a continuation an earlier Stop hook caused; read it first and allow the stop, or the hook blocks its own continuation. And the loop is capped whatever you return: after eight consecutive blocks, Claude Code ends the turn regardless. Reading the field costs one line and gets you what you wanted, one forced check per turn. Forgetting it costs up to eight continuations of a model being asked a question it already answered.

The wiring is the three-level shape module 09 taught, event, then matcher group, then handlers:

{
  "hooks": {
    "SessionStart": [
      { "matcher": "startup",
        "hooks": [ { "type": "command", "command": "python3",
                     "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/inject_index.py"], "timeout": 5 } ] }
    ],
    "Stop": [
      { "hooks": [ { "type": "command", "command": "python3",
                     "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/capture_check.py"], "timeout": 10 } ] }
    ]
  }
}

On SessionStart the matcher names which of the five sources the group applies to, so "matcher": "startup" is the compaction guard written into configuration; Stop takes no matcher at all. Where the block lives decides whom it wires. In the project’s .claude/settings.json it travels with the repository to everyone who clones it. In your user file at ~/.claude/settings.json it follows you into every project, and the wiki stops being one project’s habit and becomes yours. These two hooks are the basic wiring, and module 38 takes this further, into hooks that keep agents inside the lines once more than one of them holds the pen.

What this costs, in tokens

Everything the last two sections wired is always on, and always on has a price. The conditioning sentences load into every session. The injected index loads into every session. Every page the model opens on demand is read into the context window like any other file. All of it is measured in tokens, the unit module 27 defined as both the model’s limit and your bill, and all of it competes with the actual work for the same window.

The always-on bill

Whatever you configure to load always, you pay for always. Three rules keep the overhead small. The always-loaded part is an index, one line per page, never page bodies. Bodies are read on demand, when a page becomes relevant to the round’s work. The schema is read when writing, not on every turn.

What the overhead adds up to in your project is not a number this page can state, because it scales with what you choose to auto-load and how large your pages grow. Measure yours: module 24 built the cost surfaces that report what a session spent.

The trade is the module’s opening problem run in reverse. Knowledge that lives only in a session is free until it is lost. Knowledge that lives in the wiki costs a little every session and survives all of them. The first time someone auto-loads page bodies to save the model a read, the index rule is what they broke, and the window the work needed is what pays for it. Module 37 takes the token thread further, into keeping the main window small.

Build

The ladder:

  1. Run it. The three runs above: silence, the one-sentence philosophy, and all seven categories at once.
  2. Read one file. knowledge-base/_schema.md top to bottom. It is about a hundred lines, and it is short because every sentence in it is enforced by the lint or read by a rule the lint applies.
  3. Change one line and see it. Change app-port.md’s volatility from low to high, set KB_LINT_NOW two months ahead, and watch STALE appear: the tier is a promise about how fast the fact rots, and you shortened it.
  4. Build. Write the wiki’s second page yourself: the mail ceiling from module 33, its default of 50, and where it lives in config.js. Copy _template.md , choose the provenance honestly (you will be reading a file, so read), wikilink [[app-port]] under See also, add your _log.md line with its Why, and finish with a silent npm run kb.
The mistake most people make first

A page’s body says the fact was machine-mined, its frontmatter says verified-by: dana with a recent date, and a lint that checks fields one at a time passes it without comment: the date is a valid date, the name is a valid name, every required field is present. Now scale the mistake the way an agent-maintained wiki scales it: forty machine-extracted pages, bulk-stamped with a person’s name during some migration, every one now wearing human credibility nobody spent. Readers trust them, other pages derive from them, and the wrong ones poison quietly, because nothing about any single field is invalid. The dishonesty lives entirely in the pairing, which is why a field-at-a-time lint is blindest exactly where an agent-maintained wiki is most likely to be wrong.

The pairing rule is the fix, and it cuts both ways: an extracted page may not name a human, and a page naming a human must say how they verified. Under those two rules the forty pages become what they honestly are, a worklist of extracted pages awaiting review, visible in one lint run, and every one a row rather than a counterfeit.

Does this travel?

Entirely, and further than anything else in the track. There is no SDK call, no frame, and no Claude anything in this module: the three layers, the pairing rule, the volatility tiers, the one-fact-one-place rule, and the lint travel to Obsidian, to any markdown wiki, to a team runbook, and to the notes you keep for yourself. That is why it is built by hand first: module 35 hands agents the pen, and everything they must obey was already true before they arrived.

Check yourself

  1. A page has every required field, a recent date, and a real person’s name, and it is still dishonest. What combination of values makes that true, and which single lint category exists to catch it?
  2. Why does a TODO finding leave the exit code at 0 while a STALE finding does not, and what behavior would the other choice train into whoever runs the lint?
  3. Two sources disagree about a fact the wiki records. Write the frontmatter value and the body shape the schema demands, and say what is lost forever under the alternative.