Imported from zohaib-javd/ksor-handbook (
AGENTS.md). Install upstream withnpx skills add zohaib-javd/ksor-handbook. Copyright stays with the author.
AGENTS.md
The working contract for this Knowledge System of Record. CLAUDE.md points here; every coding agent reads this file first.
The two worlds
| Path | What it is |
|---|---|
knowledge/ |
the record — governed markdown, the owner's world, the product |
system/ |
the system — all code that serves the record |
instance.md |
what this SoR is authoritative for; its prose IS the agent surface's system prompt (ksor serve wires the body into the MCP server's instructions). Its name: is the machine identity (llms.txt, citations) and its title: is the DISPLAY TITLE every page leads with — both are frontmatter keys, both read when the server or build STARTS, so restart npm run dev after changing either (found live 2026-08-18) |
The record survives the system: knowledge/ must stay readable and complete
even if system/ is deleted. Dependency flows one way — the system reads the
record; the record never references the system.
instance.md (format: 2) carries a closed key set — name (the machine
identity), title (the display title and the root index's heading),
description (one sentence for llms.txt and the MCP discovery document),
toolchain (the upgrade stamp), site, mcp_url, version, and the four
serve-config blocks database / embedding / retrieval / budgets
(present only once you climb to the served MCP rung — see "Serving to agents"
below; a npm run dev-only project declares none). Who may read what lives in
.ksor/governance.yaml, never here. The body below the frontmatter is the
MCP server's instructions in full; npm run check names any other key rather
than ignoring it, and refuses a key repeated inside a group.
Authority lives in .ksor/governance.yaml: the audiences a document may name
(public is implicit), who may approve a document for stable, and who may
take one down. Takedowns are .ksor/takedowns.yaml, an append-only ledger
written by ksor takedown — every entry's actor is checked against the
policy, so a line appended by hand is refused exactly as the verb would refuse
it, and a deleted line is refused by ksor build against the file's history.
Display names for those actors live in .ksor/people.yaml, a small phone book
mapping each actor to the name a page should print — "human:bashiraziz": Bashir Aziz — so a page reads "Owner · Bashir Aziz" instead of "Owner ·
human:bashiraziz". The two are not derivable from each other (human:ciso is
nobody's squashed full name), which is why both are written down.
It is a SEPARATE file from .ksor/governance.yaml deliberately: the policy is
the root of authority, its key set is closed so that nothing sits there without
being enforced, and its digest is hashed into build.lock.json — so a display
name living there would mean correcting the spelling of somebody's name refused
your next build. Appearing in people.yaml confers no authority whatsoever; it
only changes what is printed.
An actor with no entry renders exactly as stored, and the governance skills offer to add one when they are about to write an actor the file does not know.
The two files list different things, and neither is derived from the other.
governance.yaml names who MAY act — an actor must be in approval_authorities
to approve a document, or in takedown_authorities to withdraw one.
people.yaml names what to PRINT for any actor the record cites. An approver
usually appears in both, for two different reasons. A document's ksor.owner
and a verified: entry's actor appear only in people.yaml, because neither
carries authority — the policy has no verification family at all. And an
authority whose handle already reads fine needs no entry at all.
Nothing cross-checks them, deliberately: a person who leaves the authority list is still the recorded approver of everything they approved, and their name must keep rendering on those acts. A governance act is history; the policy is current state.
Critical rules
- The site never contains authored content. Knowledge goes in
knowledge/, always. Never create markdown, MDX, or content pages insidesystem/site— the site renders the record, it never holds it. knowledge/is CommonMark.mdonly. No.mdx, nometa.json, no framework files. A document must read cleanly in any markdown viewer.- Never edit generated files —
system/site/.source/,.next/,out/,system/site/.staged-knowledge/(a build's per-audience copy of the record — editknowledge/, or the next build erases the change), lockfiles by hand.
Commands (run at the repo root)
npm install # once, after cloning or scaffolding (also fetches the pinned `ksor` tool)
npm run dev # the site, hot-reloading, at http://localhost:3000
npm run build # static site into system/site/out/
npm run check # the format checker — run before handing off any knowledge change
Serving to agents — the MCP rung (needs Postgres + a provider key)
ksor serve runs an MCP server over the record so agents get cited retrieval
with honest abstention. It is the climbed rung — not required for npm run dev.
Stand it up in this order (each step's errors explain how to fix themselves):
-
Check
instance.md. The one required block is already filled in — it names the environment variable holding your DSN (never the DSN itself), and there is nothing to do here unless you want a different variable name:database: dsn_env: KSOR_DB_URLThat is enough.
embedding:is optional and already defaults toprovider: gemini,model: gemini-embedding-001,dim: 1536. The other shipped vendor is OpenAI —provider: openai,model: text-embedding-3-small,dim: 1536, key inOPENAI_API_KEY— and each provider names its own key variable, so nothing else changes. Write the block out only to pin the space explicitly or to change it — and note that model and dim are the PERSISTED identity of the embedding space, so changing either later means re-embedding the whole corpus. Keepdimat or below 2000 — the schema indexes avectorcolumn directly and pgvector's HNSW takes avectorto 2000.gemini-embedding-001emits 3072 by default, so ksor asks it for 1536, which the provider's own dimensionality table shows costs nothing measurable against the ceiling — going the other way trades a little quality for storage. Whether to move is priced in ksor's decision 30, which carries the figures and their source.Leave
retrieval:out for now — the gate is off and the server says so. Turning it on is step 4, AFTER the record is serving. -
Get the database — your agent can do this one.
.mcp.jsonat the repo root declares the MCP servers this project may reach. It ships with two:Neon, which provisions the Postgres this step needs, andagentfactory-system-of-record, a read-only KSoR record Panaversity operates as an example of the surface being built here — not this project's record, and not needed by anything below. Either can be deleted; the file is the adopter's.The Neon server acts on the whole Neon ACCOUNT, not on one database: it can create and delete projects and branches. Show the plan before running it. With it connected, ask:
Using the Neon MCP server, create a project called
<your-record>and enable the pgvector extension on it. Then create a branch calleddev, and save that branch's connection string to.envasKSOR_DB_URL. Never print my API key. Show me the plan before you run anything.That is
create_project,run_sql(CREATE EXTENSION vector),create_branchandget_connection_string— four real tools, no dashboard. Prefer the OAuth flow: an API key pasted into.mcp.jsonwould be committed, because that file carries no secret and is not gitignored.Any Postgres with pgvector works — Neon is the path with an MCP server, not a requirement. Locally:
docker run -e POSTGRES_PASSWORD=x -p 5432:5432 pgvector/pgvector:pg17.GEMINI_API_KEYis the one step no agent can do. No vendor mints an API key over a protocol; it comes from a browser at aistudio.google.com/apikey and a human pastes it. An agent working through this list should ask for it by name and stop, rather than trying — and should say that the FREE TIER is enough: embedding input ongemini-embedding-001is free of charge, so this is a signup, not a bill. -
Copy
.env.exampleto.envand fill it in —ksorreads it automatically, so nothing needs exporting, and.envis already gitignored. A real environment variable still wins over the file, so CI and production overrides behave normally.cp .env.example .envThree values matter:
KSOR_DB_URL— the Postgres store named byinstance.md'sdsn_env. It needs the pgvector extension:CREATE EXTENSION vector;GEMINI_API_KEY— the embedding provider key.KSOR_AUTH=disabled-local— required for a local run.ksor serverefuses to boot unauthenticated without it, deliberately, so a server is never left open by accident. It binds loopback, where auth off is the intended dev shape. A PUBLIC deployment configures the SSO door instead — see the comments in.env.exampleand "Serving safely" below.
-
Bring it up. Once, then every time:
npm run provision # schema (or migrate) + grant — the privileged acts, run once npm run refresh # build, ingest the record, collect retired generations npm run serve # the MCP server (one supervised process)provisionis separate on purpose: applying DDL and granting ingest are acts an operator performs, not side effects of starting a server. (It is not calledsetupbecause package managers claim that word for commands of their own, which would shadow it — the step would print "No changes to the environment were made" and do nothing.)Deploying to a container runtime you do not control (Cloud Run, Fly, Container Apps — anything that scales to zero and hands you a
$PORT):ksor serveis already shaped for it, and the posture is deliberate.- It binds
$PORTon0.0.0.0when the platform sets one. - It holds no idle database connections. The pool minimum is 0 and an unused connection is closed after 10s, so an idle instance keeps nothing open against a serverless Postgres — and a busy one still reuses connections instead of paying a TLS handshake per request.
- The first request after an idle period wakes the database, and that connect is retried rather than failing: a cold start is a transient, not a refusal.
SIGTERMdrains and exits within 8s, inside the ~10s a runtime usually allows beforeSIGKILL.
What it does NOT do is open and close a connection per request. That is the pattern connection poolers exist to remove — the handshake alone is ~26x the cost of a pooled query even on localhost, before TLS — and it is not what managed Postgres vendors recommend for a process that serves many requests. Per-request connections are the right shape only for a per-invocation runtime (an edge function), which is a different deployment and would want an HTTP database driver rather than TCP.
Set
KSOR_SNAPSHOT_KEYSfor any such deployment: without it each cold start mints a new signing key, so citations pinned before a scale-down stop validating after it.npm run serveserves; it does not publish. It isksor serveand nothing else — it opens the port against whatever generation is already active and needs no ingest privileges. Publishing isnpm run ingest(ornpm run refresh), and it is a separate step ON PURPOSE: a container that re-ingested on boot would pay the whole record's embedding cost on every cold start and would need write credentials at runtime. So in a deployment, runnpm run provisionandnpm run ingestas DEPLOY steps and runksor servein the container, where it honours$PORTand binds0.0.0.0. If you skip the ingest step, the container serves the last generation you flipped — and on a FIRST deploy, that is nothing at all.Every step is re-runnable, so this is also how you refresh after editing
knowledge/: an applied schema reports "already applied", an existing grant reports "already granted", and ingest builds a fresh generation.npm run refreshafter editingknowledge/;npm run serveto bring the server up. Two commands, and the split is the point: serving must not publish, or a restart, a crash-loop or an autoscaling event each republishes your record. Everything is re-runnable and reports the state it found rather than failing — an applied schema says "already applied", an existing grant says "already granted".A refresh on an unchanged record costs nothing at all: ingest compares the corpus it just read against the generation already serving and, when they are identical at the same commit, consumes no generation and writes no rows ("unchanged — generation N already serves this corpus"). Edit a document and the next refresh builds a generation for it, re-embedding only what changed and carrying the rest forward by content hash — so an ordinary edit makes a handful of provider calls, not a corpus-worth.
Generations do accumulate as you edit. Reap the superseded ones when you think of it, or on a schedule:
npx ksor gc --instance instance.mdThe individual verbs (
npm run schema,npm run grant,npm run ingest,npm run gc) are whatprovisionandrefreshare made of. Reach for them when duties are split — a deploy step that ingests while a different process serves, or a DBA who holds the credentials that authorize ingest — not as a daily choice. - It binds
-
Turn the abstention gate on — deliberately, once it serves. This is the step that makes "not in this corpus" a real answer, and it is measured, never guessed:
npx ksor calibrate --instance instance.mdIt ends with a
retrieval:block for THIS corpus in THIS embedding space, ready to paste into instance.md's frontmatter exactly as printed. Paste it and restart:retrieval: vector_floor: 0.55 # calibrated <date> on generation 3, model <model>/d1536, door: synthesized floor_digest: 8bfb07d0e6f5If instance.md already has a
retrieval:block, merge the keys into it — a secondretrieval:is a duplicate key and is refused.Never copy a floor from another corpus — recalibrate, and record the measurement beside the number. Writing
vector_floor: uncalibrateddeclares the intent to gate WITHOUT a measurement, and every serve refuses until a number replaces it; that is the fail-closed posture, not a starting point.The same applies across TIME, not only across corpora. A floor measured against 5 documents is a copied constant once the record holds 200, and it weakens in silence: questions that used to be out-of-corpus start scoring above a fixed number, so the record answers what it used to refuse.
npx ksor calibrate --instance instance.md --checkreads the record's own logged searches and reports how the declared floor is holding — no provider key, no LLM, one query, and it always exits 0. Run it on a schedule; when it says WATCH, re-run
ksor calibrateto get a new number. It is a monitor, not a measurement: it can say the floor has gone permissive against real traffic, never that it is too strict for questions nobody asked.
npm run schema # apply the DDL (once)
npm run grant # authorize ingest for this corpus (once)
npm run ingest # embed knowledge/ into a generation and activate it
npm run serve # run the MCP server; any other verb: npx ksor <verb>
How serving updates work (the generation model)
Each ksor ingest builds a fresh generation (invisible until activated) and
carries every unchanged embedding forward from the last complete generation —
so re-ingest is safe and cheap; only changed or failed chunks re-embed.
--flip swaps the active pointer, guarded by a catastrophic-shrink check
(KSOR_MAX_SHRINK, default 0.15 — a flip that drops more than 15% of nodes
refuses; override with KSOR_ALLOW_SHRINK=1 when the shrink is intended). The
previous generation stays as a rollback target; npx ksor gc collects
abandoned ones.
Serving safely (fail-closed posture)
npm run serve refuses to boot unauthenticated — there is no auth-off
default. A local run says so deliberately with KSOR_AUTH=disabled-local and binds
loopback, which is the intended dev shape. A public
bind refuses to boot unless auth is configured (KSOR_SSO_URL +
KSOR_MCP_RESOURCE_URL + KSOR_JWT_ALLOWED_AUDIENCES, making it an OAuth
Resource Server) OR you deliberately set KSOR_AUTH=disabled-public.
Never let a dropped auth variable silently ship an open door. On a non-loopback
bind, set KSOR_ALLOWED_HOSTS / KSOR_ALLOWED_ORIGINS; on more than one
replica, set a shared KSOR_SNAPSHOT_KEYS (unset ⇒ a per-process key, so a
search token minted by one replica fails on another).
Three things worth being deliberate about:
KSOR_AUTH=disabled-publicserves your whole record to anyone who can reach the port. It exists for deployments fronted by your own gateway or network policy. If nothing else is in front, do not set it.- Set
KSOR_SSO_ISSUERwhen your SSO stamps a stableiss. Audience is always enforced againstKSOR_JWT_ALLOWED_AUDIENCES; naming the issuer adds one more check for the cost of one variable. - The signing keys are DISCOVERED; you rarely set
KSOR_JWKS_URL. The door reads your SSO's own metadata document — RFC 8414 (/.well-known/oauth-authorization-server), then OpenID Discovery (/.well-known/openid-configuration) — so Auth0, Okta, Entra, Keycloak, Cognito, Google and Better Auth all work unmodified. The boot report'skeysline names which document answered and where the keys came from; setKSOR_JWKS_URLonly to override that, or when your SSO publishes no metadata at all.
What a CLIENT has to do
Once the SSO door is configured (the three variables above), the server is an
OAuth Resource Server, which means a client is not told the authorization
server — it discovers it. Nothing here needs configuring beyond those variables;
this is what your agents will experience, and what to check when one cannot
connect. With KSOR_AUTH=disabled-local — the local default .env.example ships —
none of it applies: there is no challenge and the metadata document answers 404,
because there is no authorization server to point at.
-
The client calls
POST /mcpwith no token and gets 401 carryingWWW-Authenticate: Bearer resource_metadata="https://<your-host>/.well-known/oauth-protected-resource/mcp"That header is the whole handshake: it names a DOCUMENT, not the resource.
-
The client fetches that document and finds the record's resource identifier and its authorization server:
{ "resource": "https://<your-host>/mcp", "authorization_servers": ["https://your-sso.example.com"] }Those two values are
KSOR_MCP_RESOURCE_URLandKSOR_SSO_URL. -
The client gets a token from that authorization server, asking for THIS record as the resource (RFC 8707:
resource=https://<your-host>/mcp), and sends it asAuthorization: Bearer <token>.
Every 401 carries that same header, not just the first one — including the one
your clients will hit most often, a token that expired mid-conversation. It
arrives as Bearer error="invalid_token", resource_metadata="…", so a client
knows to refresh rather than to retry the dead token. A 503 is deliberately
not challenged: an unreachable key set is our outage, not your token's fault,
and telling a client to re-authenticate over it would send a perfectly good user
back through a login.
The one thing that goes wrong here goes wrong quietly: a token minted for a
different audience is a perfectly valid token, and this door rejects it. The
aud claim must match one of KSOR_JWT_ALLOWED_AUDIENCES — normally the same
value as KSOR_MCP_RESOURCE_URL — because a bearer accepted for any audience is
a bearer stolen from one service and replayed against this one. If a client
authenticates fine and still gets 401, compare its token's aud against that
list before looking anywhere else.
Tokens must be signed RS256; nothing else is accepted, and opaque tokens are not supported (there is no introspection call). When your SSO rotates its signing keys, an unknown key id answers 503, not 401 — the token may well be good and the door's key set merely stale, so a client should retry rather than send the user back through a login.
Shaping the agent surface — system/gateways/content.ts
That file is this record's MCP registration — ordinary registerTool with
ordinary zod. It is yours, and it is deletable: without it the door serves
the same defaults.
Edit it because an agent pays for this surface out of its context window, twice.
The definitions depend only on the code, so they are exact for every record
(re-measured 2026-08-25 from the served tools/list); the per-call figures are
the 2026-08-23 measurement against a live 81-document record, plus the
governance block every hit now carries:
| all three tool definitions | ~4,349 tokens, always resident |
one search at k=10 (default) |
~4,196 tokens per call |
one search at k=5 |
~2,330 tokens per call |
Three edits pay for themselves:
- Delete a tool nothing calls. Removing
outlineandreadgives back ~2,310 tokens for the whole session. - Say what this record covers, above
FLOOR.search. It is how an agent with several records attached picks yours; name the subject AND the boundary. - Set
kin the input schema — it is the lever on reply size.
description: `Leave, benefits, conduct. Not product docs.\n\n${FLOOR.search}`,
inputSchema: z.object({
query: z.string(),
k: z.number().int().default(5),
min_trust_tier: z.enum(TRUST_TIERS).optional(),
}),
Keep min_trust_tier. Dropping it does not weaken the record — the handler
still applies unverified and this deployment's own floor — but it takes away
the only way a caller can ask to be answered ONLY from what a human reviewed,
and the door says so at boot with a notice naming the tool and the line to
paste, rather than failing quietly.
You can add your own tools with registerTool too — but be clear-eyed: ksor
makes no provenance claim about a tool it did not hand you a handler for.
What you cannot change is deliberate: the handlers, the output schemas, and the
FLOOR text. Your prose goes ABOVE the floor, never instead of it — the floor
tells an agent how to read an abstention and that corpus content is untrusted,
and a record that dropped it would answer without ever declining.
Because that is a template literal in a file you own, the door checks its own
surface at boot and refuses to start if a guarantee is gone:
ksor-gateway-floor-missing, ksor-gateway-no-tools,
ksor-gateway-unloadable. Full detail:
node_modules/@panaversity/ksor/docs/tool-surface.md.
Withdrawing a document — ksor takedown
A takedown is the one governance act that must reach EVERY surface at once. It
is written to the committed ledger .ksor/takedowns.yaml FIRST and to the
database denylist second, in one act — so a record with no database can
withdraw a document, and the site reads the withdrawal from the repository
rather than from anything exported.
npx ksor takedown --instance instance.md --actor human:you <stable-id> --reason "legal request 2026-08"
npx ksor takedown --instance instance.md --actor human:you <stable-id> --reason "..." --scope subtree
npx ksor takedown --instance instance.md --list # what is currently denied
npx ksor takedown --instance instance.md --ledger # who denied what, when
npx ksor takedown --instance instance.md --actor human:you --revoke <entry-id> --reason "..."
The stable id is what a search result reports as provenance.stable_id, and it
is knowledge/<path-without-.md> — always, since path is identity.
--scope subtree withdraws a section and everything beneath it, including
documents added later. --revoke takes the id of a LEDGER ENTRY, not a stable
id: the ledger is append-only, so a lift is a new entry rather than a deleted
line. The id is printed by the denial that created it, listed by --ledger,
and written in .ksor/takedowns.yaml — three ways to the same string, none of
which needs a database. --removed <entry-id> records that what a denial
names was deleted — a document, or at --scope subtree the directory — and
--apply writes the rows for entries that reached the database late. Record
the deletion; do not revoke instead: a revocation says the hold was LIFTED,
which is a different act, and it stops covering the path if it ever returns.
--actor names who performed the act, and a denial, a revocation or an
amendment is REFUSED without it. There is no default: a name taken from the
environment reads like a person and is whatever the shell happened to be
(runner under CI, root in a container), which is worse than no name at all
in the one row that exists to record who did this. It must be a well-formed
actor (human:<handle> or process:<id> — a bare name is refused) and
takedown_authorities in .ksor/governance.yaml must name it. The same check
runs over every entry in the ledger at npm run check, ksor build and ingest, so
a line appended by hand in a pull request is refused exactly as the verb would
refuse it. The read-only modes
(--list, --ledger) need no actor — nobody is performing an act by looking.
They do not need a database either: --ledger always reads the committed
.ksor/takedowns.yaml, which is the record of every act, and --list reads
the door's denylist rows when KSOR_DB_URL is set and otherwise the ledger's
denials, each labelled not applied (no database) because no row exists for
a door to refuse on. A denial itself, on this record with KSOR_DB_URL unset,
is refused by name (ksor-takedown-dsn-missing) unless --file-only records
the entry alone — --apply writes its row later, where the database is
reachable.
The MCP door stops serving it immediately. The SITE stops at its next
build — the site reads the committed ledger (.ksor/takedowns.yaml), not
the database, and so needs no database access to honour a takedown. After a
takedown, merge the ledger entry, rebuild and redeploy the site, or the human
surface keeps publishing what the agent surface already refuses.
Publishing
npm run build emits a fully static site (system/site/out/) deployable to
any host — Vercel reads the shipped vercel.json (deploy from the repo
ROOT, never system/site/), and every other host just serves the folder.
npm run build runs ksor build first — every index.md regenerated, the
record checked, build.lock.json written (commit it: it is the provenance
every machine artefact stamps) — and a refusal stops the build before a byte
is written. ksor build --strict additionally refuses an uncommitted input
(ksor-build-dirty), which is the posture for a release. ksor build --bundles also writes one OKF bundle per viewer under
.ksor/out/bundles/<viewer>/ (gitignored) — only what that viewer's machine
surfaces publish, readable by any OKF consumer with no ksor in the loop — for
handing the record to another system.
KSOR_BASE_PATH=/repo npm run build targets sub-path hosting. With audiences
registered in .ksor/governance.yaml, plain npm run build is the public
viewer; KSOR_AUDIENCE=public,<audience> npm run build — a comma list, always
including public — builds for a wider viewer, and that build belongs
behind that audience's own access control, never on a public host.
Details in README → Deploying.
The starter publishes; what the owner writes does not, until they approve
it. The five starter documents ship status: stable, approved by
ksor-starter/0.0.60 — a PRODUCER actor, not a person, and
.ksor/governance.yaml authorises it so the first npm run build emits a record
with something in it. Two things follow, and both are your job. The samples
describe KSoR rather than this organisation, so replacing them is the owner's
first act on this record; and when the last one goes, the producer goes with it
— delete ksor-starter/0.0.60 from approval_authorities, because
nothing the owner wrote should be approved by a tool.
Everything written after that starts at status: draft, and §2.5 admits a
draft to NO surface of a build — not the page, not the sidebar, not the search
index, not llms.txt. A build of a record whose documents are all drafts emits
a site with zero document pages and an empty ## Documents section, reports
0 admitted to a machine surface, and is working correctly. npm run dev shows
the drafts, which is what the preview is for. Never approve on the owner's
behalf to make a build look fuller: approving is status: stable plus a
ksor.approval: { by, at } naming an actor .ksor/governance.yaml authorises,
and it is the owner's act. Ask them, then write down what they said
(.agents/skills/intake-interview/). KSOR_DRAFTS=show npm run build publishes
drafts to the HUMAN surface only, marks the build noindex and records itself
in build.lock.json — a review link, never a way to ship.
The MCP door is a container
The other surface is a live process, so it ships as one. Dockerfile and
.dockerignore are yours, at the repo root, and they name no host:
docker build -t my-record .
docker run --rm -p 8080:80 --env-file .env \
-e KSOR_AUTH=disabled-public my-record
The -e is required and must not be dropped: the image sets $PORT, so the
door binds 0.0.0.0 — a PUBLIC bind — and the KSOR_AUTH=disabled-local in
.env refuses there, correctly. Never "fix" that refusal by editing .env;
put the deliberate value on the command, so the owner's ordinary npm run serve
keeps its loopback posture. A real deployment sets it — or the SSO variables —
in the host's environment, since .dockerignore keeps .env out of the image.
That image runs on Cloud Run, Fly, Render, ECS, Kubernetes or a VPS unchanged.
vercel.json declares BOTH surfaces — a site service built from
system/site/out/ and a door service pointing at that same Dockerfile —
with rewrites putting the door on /mcp, /health, /ready and the
/.well-known/oauth-protected-resource document, and the site on everything
else. Two rules if you edit it: the /(.*) catch-all must stay LAST, and do
not add a project-level trailingSlash — the site's Next config already sets
it, and at project level it 308-redirects POST /mcp, which breaks the door.
The image deliberately excludes .env (a baked DSN is published to anyone who
can pull the image), knowledge/ (the door reads Postgres, never the folder)
and system/ (the other surface).
Deploying does not publish. A container that ingested on boot would pay the
whole record's embedding cost on every cold start and need write credentials at
runtime. So npm run refresh is a DEPLOY step you run — from your machine or from
CI — and a first deploy without it serves an empty record. Full walkthrough:
node_modules/@panaversity/ksor/docs/deploying.md and …/docs/ingesting.md.
Writing knowledge
-
One document per file under
knowledge/; the path is the document's identity and its URL — ascii lowercase, digits, hyphens; no spaces or special characters; no two files differing only in case; never bothfoo.mdand a folderfoo/. Thetitle:carries the document's real name in any language — the filename is the address, not the name. Every folder'sindex.mdis GENERATED byksor build(an OKF §8 index of the folder) and committed — never edit one, never put prose in one; a folder's own introduction is a named document inside it, such asoverview.md.log.mdandREADME.mdare reserved names. -
The frontmatter
titleIS the rendered page heading — never repeat it as an# h1in the body, and quote any value containing a colon (title: "Note: quoting"). -
Frontmatter is the KSoR Profile of OKF (the record spec's §2). Required on every document:
type(Documentunless the knowledge is aPolicy,Procedure,Control,Standard,Definition,Decision Record,ExampleorAttested Computation— those reserved types also requiresourcesandksor.owner),title,description(one sentence),status(draft | stable | deprecated) andksor.audience(a list —[public], or audiences registered in.ksor/governance.yaml; never omitted, never inferred). Astabledocument carriesgenerated: { by, at }andksor.approval: { by, at }by an actor the policy authorises, withgenerated.atno later than the approval — andgenerated.atdates the TEXT: edit a stable document's body andksor buildandksor ingestcompare it against every committed version that was stable, and refuseksor-generated-staleuntil you move the stamp PAST the edit and re-approve — leaving the stamp alone and backdating it are refused alike, because neither advances it (a frontmatter-only edit, such as adding averifiedentry, is not a body change;npm run checkreads no document history, so it passes an edit the build will refuse); adeprecatedone carriesksor.deprecated: { by, at }and usuallyksor.superseded_by: <id>(a stable document every reader of this one may read). Optional:order(reading position),sources({ id, resource, title }, cited in the body as GFM footnotes[^id]),verified([{ by, at }]— sets the trust tier: none → unverified, machine actors → machine-confirmed, anyhuman:→ human-reviewed; unlikeksor.approval, the policy does not gate WHO may appear here — it has no verification family — so averifiedentry is a claim the document makes about itself and the pull request that adds it is the only thing standing behind it),stale_after,ksor.effective_from. Actors arehuman:<id>,process:<id>or<producer>/<version>inverified,generated,ksor.approvalandksor.deprecated— anything else there is refused.ksor.owneris not checked for its shape: write an actor orteam:<id>by convention, but it is free text, so a bare word passes and can then never be theksor.deprecated.bythat deprecates the document. Every timestamp is an ISO 8601 instant with an offset (2026-08-25T09:00:00Z) — never a bare date. Unknown keys are preserved, unless the name is one edit from a profile key —stale_afer:is refused rather than kept, because a preserved near miss is the key it meant, failing open. Theksor:block's own key set is closed. The pre-profile keysvisibility,owner,provenance,effective,supersededandsuperseded_byare refused by name andksor migratemoves them;idandnameit deletes (the path is the identity);sor_idit REFUSES rather than drops, because retiring it changes the document's stable id and any takedown keyed on the old one must be re-denied against the new one first. -
Each page says how long it takes to read, counted from the document's own words when the site is built. Fenced code and frontmatter do not count toward it, so a short page carrying a long example is not reported as a long read. Nothing to author — it is derived. It sits with the owner and the effective date, in the row under the title; a document with a SUMMARY moves it onto that view's own strip instead, where it reports the view you are reading rather than the document. Each tab names its own figure to a screen reader, so the cost of each view is available without switching to it.
-
The governance keys are rendered, so they are worth filling in. Each page shows its owner and effective date under the title, lists every
sourcesentry separately at the foot, and — for a deprecated document — carries a notice above the title naming its successor and linking to it. A key you leave off renders nothing at all: the site never invents a value, so a missing owner reads as missing rather than as unowned. -
The agent surface carries them too — by EXCLUDING what it must not hand over.
llms.txtandllms-full.txtlist only what the §2.5 table admits to a machine surface: stable, effective, unexpired, undenied. A draft, a deprecated document, and one whosestale_afterhad passed when the build ran, are not entries at all, so an agent is never handed a withdrawn document as plain prose.llms-full.txtserves each document's own frontmatter intact, plus the derivedtrust_tierand this build's stamps — so what an agent reads carries the same governance a reader sees on the page. -
Those files are a SNAPSHOT, and keeping them true is yours. A build decides admission ONCE, at its own instant, and writes the answer into files; static output cannot re-decide itself. So a document whose
stale_afterpasses AFTER a build keeps appearing inllms.txtand in its markdown twin until the next build, whileksor serve— which evaluates the same rule per request — already refuses it.ksor buildprints the next instant at which this goes out of date, and prints which documents it held back and why. Nothing here rebuilds on a schedule: the shippedvalidate.ymlruns the checker on pull requests andvercel.jsondeclares no cron. If this record usesstale_afterorksor.effective_from, add a scheduled rebuild and redeploy, or accept that the static half is as current as the last build. -
Don't want any of it on the published pages? Set
governance: falseundersite:ininstance.md. The record keeps every key — the agent surface and your audit trail still read them, andllms.txt/llms-full.txtkeep publishing them — and the pages simply stay plain. One consequence worth knowing: the home page shows the agent surface VERBATIM in its panel, so the keys stay visible there even with this off. That panel's whole claim is that it is the bytes an agent is served, and editing them to match a page setting would make it lie. Remove the panel if you need the front page silent too. What it does not hide is a CAVEAT: the supersession notice stays, and so does the chip on a document the record has not brought into force yet or has not reviewed since itsstale_after— a reader handed one of those with no word of it has been misled, and the sidebar, the listings and the search results say it whatever this key is set to. -
statusis shown only when it is a caveat.deprecatedappears as a small label;stableshows nothing, because a reader already assumes a document in the record is current — so the label stays rare enough to be noticed on the pages where it matters.draftcarries the same label, but only undernpm run dev: a BUILT site has no draft page to label it on. -
ksor.audiencelists who may read a document; a viewer holds a list that always includespublic, and the document is visible when the two overlap. Every identifier butpublicmust be registered in.ksor/governance.yaml— an unregistered one is refused, because a typo reads as a restriction.npm run checkrefuses any link,ksor.superseded_bypointer or summary that reaches a document not every reader of the source may read (ksor-link-widens) — the leak no single build can catch, because the build that publishes the link has already dropped its target.Publication, not authorship: anyone who can clone the repository reads every document regardless of frontmatter; if someone must not read a document and can clone, the answer is a second repository.
-
A replaced document is marked
status: deprecatedwithksor.deprecated: { by, at }(a takedown authority, or the owner anownership:rule in.ksor/governance.yamlresolves —ksor.owneron the document is not that owner, and this record's policy declares no rule yet) andksor.superseded_by:naming its successor by id (policies/refunds-v2) — deprecated documents are never deleted. The successor must exist, bestable, and be readable by every reader of this document, or the pointer strands them (ksor-supersession-strands). -
Images and assets live in
knowledge/beside the document that uses them, referenced by relative links. A relative link must never leaveknowledge/. -
Study attachments. A document may carry four optional companions named after it, in the same folder:
<doc>.summary.md(a short précis),<doc>.slides.yaml(a presentation),<doc>.flashcards.yaml(a recall deck) and<doc>.quiz.yaml(a multiple-choice check). The summary appears as a second tab beside the document's own words; the presentation appears after the document's INTRODUCTION — everything before its first##section — because a deck is the shape of the thing and belongs where the reader has just been told what the thing is; the deck and the quiz appear at the END, because those are used after reading. None of them appears anywhere else in the site.An attachment is part of its document, not a document. It has no URL of its own, no sidebar row, no line in
llms.txt, and no identity an agent can cite — so a summary's frontmatter is exactlytype: Summaryand nothing else (the checker refuses any other key), and every attachment takes its audience and any takedown from its parent. Restrict the document and its summary and deck go with it; there is no way to publish a summary more widely than the document it summarises. An attachment whose document is missing is refused, bynpm run checkand bynpm run buildalike.A deck is YAML, and the extension is exactly
.flashcards.yaml—.ymlis refused by name:deck: title: Expense approvals description: Recall checks for the approvals policy. cards: - front: Who approves a purchase above the threshold? back: A second approver, independent of the requester. why: Optional — a prompt shown before the answer.No
id:anywhere, on the deck or a card: the path is the deck's identity, and a card's identity is its own text. Edit a card and only that card's review progress starts again; the rest is untouched.A card may only say what its document says. The summary and the deck are ways of rehearsing the record, never a second source — a card asserting something its document does not is a claim nothing governs and no agent can cite. Ask your coding agent to write them from a document and to check every answer back against it.
Review scheduling uses a simple interval ladder (an SM-2 variant): a missed card returns in about a minute, a recalled card's interval grows by roughly 2.5x each time. It is not FSRS and makes no retention guarantee. Progress is kept in the reader's own browser, so it is per-person and per-device, and it is not part of the record.
A quiz is the same idea with an answer to choose. Each question carries two to six options,
answeras the zero-based index of the correct one, a requiredexplanation, and an optionalsourcenaming where in the document the answer lives:quiz: title: Expense approvals questionsPerRound: 10 # optional; the default questions: - question: Who approves a purchase above the threshold? options: - A second approver, independent of the requester - The requester's own manager, in every case - The finance team, after the purchase completes answer: 0 explanation: Why that is right, and why the others are not. source: Approvals — thresholdsThe reader sees whether they were right immediately, then the explanation — a quiz here teaches through the mistake, and it does not certify anybody, so there is no pass mark. Answers stay in the reader's browser and are sent nowhere. A bank larger than
questionsPerRoundis drawn from at random and offers another round; a smaller one is simply asked in full.npm run buildrefuses a quiz a reader could pass without reading (the audit runs in the site build, not innpm run check), and this is worth knowing before you write one, because it is easy to trip by accident:Refusal Means ksor-quiz-answer-biasmore than 60% of answers sit at one option position ksor-quiz-length-biaspicking the longest (or shortest) option usually wins ksor-quiz-answer-runfour or more questions in a row share an answer ksor-quiz-contradictionan explanation calls the marked answer wrong ksor-quiz-duplicate-stemtwo questions open with the same 60 characters So: move the correct answer around, and keep the options close in length. The ratio rules do not apply below five questions, where spreading answers would mean the checker choosing them for you. These checks come from the predecessor, where the same mistakes shipped and were found by readers — one quiz had every correct answer in the same position across 451 questions.
A presentation is slides the record owns. Ask your coding agent for one rather than writing it by hand: it reads the document whole, writes the deck, checks every claim and every number back against the document, and tells you what it left out because the document did not support it:
slides: title: Expense approvals description: The 15-minute version, for a room. deck: - heading: When two copies disagree, one wins bullets: - Two approvers above the threshold, always - The threshold is per invoice, including tax note: Spoken, never shown. Pause here — people remember this one wrong.headingis required and should be a claim rather than a label;leadis one sentence for a single-point slide;bulletscaps at six because a slide someone reads aloud is a slide nobody listens to;noteis what the presenter SAYS and never appears on the slide, including in fullscreen.The slides render in the page — no third party, no request to anyone, and every slide is in the shipped HTML, so a reader without JavaScript still gets the whole deck. Because they live in the record they are reviewed in the same pull request as the document, versioned with it, and withdrawn with it.
A deck you keep elsewhere can be pointed at instead —
slides.url:and nodeck:. The embed url is derived for Google Slides, Canva and SlideShare; anything else needs an explicitembed:or renders as a link. The url must behttps(a browser blocks a mixed-content frame silently), and the frame loads on CLICK, so nothing is requested from the host until a reader asks. Declaring bothdeck:andslides.url:is refused — two presentations with nothing to say which one governs. -
One instruction per tool, as tabs. When a document has to say the same thing two ways — one command for one agent, another for another — put each in its own fenced block and give the fence a
tab:```bash tab="Claude Code" tab-group="agent" curl -fsSL https://claude.ai/install.sh | bash ``` ```bash tab="OpenCode" tab-group="agent" curl -fsSL https://opencode.ai/install | bash ```Consecutive blocks that declare a
tabbecome one tab group. This is still CommonMark: a fence's info string is free text, so any other markdown reader shows both blocks one after another — correct, just without the picker.tab-groupis what makes the choice stick. Blocks sharing a group name switch together across the whole page and the choice is remembered for the reader's next visit, so a document with ten tabbed sections is one decision rather than ten. Leave it off and each group is independent, which is almost never what you want. Use the same group name for the same axis throughout a record.A tool the site knows gets its own colour and mark on its tab; anything else renders in the site's accent. The list lives in
system/site/app/global.cssand is yours — adding a tool is one colour and one mark. -
A passage the reader must not miss, as a CALLOUT. GitHub's alert syntax — a blockquote whose first line is the kind in brackets:
> [!WARNING] > This threshold changed in March. The figure below is the old one.Five kinds:
NOTE,TIP,IMPORTANT,WARNING,CAUTION. The site tints the panel in that kind's colour and rules its left edge; every other reader of the record — GitHub, a plain editor,/md/,llms-full.txt— shows an ordinary blockquote carrying a visible label, so nothing is lost and nothing becomes punctuation.Not
:::warning. That form is a dialect: a record written in one renders as literal colons in every reader except the one site that understands it. -
A long line is the reader's to unwrap. Nothing to author — a fenced block wider than the column gets a button beside its copy button that wraps it, and a block that fits gets no button at all.
-
Something running, as an embed. A document that wants to show a page in motion — a simulation, a player, a dashboard — links to it and gives the link the title
embed:[Play run-until-done](goal-loop.sim.html "embed")Still CommonMark: a link title is a tooltip everywhere else, so GitHub, a plain editor,
/md/andllms-full.txtall show the author's link. Nothing loads until a reader clicks, which is what keeps a built page free of external requests — and the panel names what it is about to reach, so the click is informed.You do not state a height. A page carried in the record is measured, so the frame is exactly as tall as what it holds, to the pixel. A number written into a document would be a number some other measure makes wrong.
Carry the page in where you can. A file named
<name>.sim.html, sitting beside its document exactly like a figure, is published by the build and served from this site — so it works offline, tells nobody outside what someone is reading, and is versioned with the document instead of changing under it. It is an ASSET, not an attachment: named freely, as many per document as the prose needs.An
https:link works too, for a page you cannot carry. It is the weaker option for a reason worth knowing: many hosts sendX-Frame-Options: SAMEORIGIN, which forbids any other site from framing them, and a browser enforces that whatever this record does. Check before you rely on one. -
Copy load-bearing values (numbers, thresholds, dates) exactly from their source, name the source in
sources, and cite it from the claim with a GFM footnote whose label is that source'sid. -
The record says only what its source says. A gap is an open question written into the document — never filled from general knowledge — and two sources that disagree stay two cited statements: flag the disagreement to the owner rather than choosing. That boundary is the product; a record that quietly knows more than its sources is a chatbot with a sidebar.
Structuring the record
- A folder per topic. Its
index.mdis GENERATED byksor build— an OKF §8 map of the folder — so a folder's own introduction is a named document inside it, such asoverview.md, and the folder sorts where its lowest-order:document does. - Sidebar position is the governed
order:key: documents that declare it come first, ascending; the rest follow in name order. Folders sit in that SAME list, not after it — so a folder can sit between two documents, and where it sits is decided by theorder:of the documents inside it. - One order drives every surface — the sidebar,
llms.txt, the home page's first-document link, and the MCPoutlinetool an agent reads to decide what to read first. Set it once and they agree. The door picks up a reorder at the nextnpm run refresh, which costs no embedding: only the ordering changed. - Never
meta.jsonorsidebar_position: the checker refuses framework files in the record, which has to read the same without the site.
Skills
.agents/skills/intake-interview/— first run: interview the owner and writeinstance.mdtogether..agents/skills/add-sources/— turn what the owner has into governed knowledge: a document, a page, notes, or what they tell you that nobody wrote down. Converts files with a verified extraction, interviews a person for the rest, and ends with the owner's approval..agents/skills/format-checker/— the rules above, as a program;npm run checkruns it and its errors explain how to fix themselves.
Customizing the site
You own system/site/ outright — these are the seams, cheapest first:
- Display title —
instance.md'stitle:key (the intake interview writes it). Headline, navbar, and browser title follow on restart. - Accent color — the one brand pair in
system/site/app/global.css(--primaryand--primary-foreground, light and dark); every accented element follows, in the shell and in every shadcn component alike. - Logo and favicon — replace
system/site/app/icon.png; the tab icon and the home-page mark are the same file. - Components — the site is a shadcn/ui project (
system/site/components.json), sonpx shadcn@latest add <name>writes a component intosystem/site/components/ui/that you then own like everything else here. Fumadocs reads the same tokens (itsshadcnpreset maps every--color-fd-*onto the shadcn variable of the same role), so a registry component and the documentation shell around it stay one palette. - Anything deeper — edit the site like the Next.js app it is; the only
rule that survives customization is critical rule 1. The whole shell is
replaceable behind a five-clause contract: render the record,
llms.txt, per-page md artifacts, browser smoke, and no authored content.
What this project owns
Everything. The scaffold was emitted by ksor init (version recorded in
instance.md) and belongs to this repository outright — change anything in
system/ deliberately; the knowledge in knowledge/ was always yours.
