Knowledge base pages
Page format, paths, limits, routes, and folder sync
Every project has one knowledge base: a tree of markdown pages under /fs/ that
outlives every session. Agents write it with the brain tools and
store: "project"; the human reads, edits, reviews and promotes over the REST
routes below. Both surfaces share one write path, so a page gets the same path
rules, the same size limit, the same log row, the same search row and the same
feed signal whoever wrote it. The decision behind the store is
ADR 0018, and the agent tools are in
MCP tools. The wiki the human reads is in
Guides.
The shape of a page
A page is markdown with a YAML frontmatter block. The hub reads it, and the wiki shows it as the page's type, status and tags rather than as body text. These are the keys the reader knows:
| Key | Value |
|---|---|
type |
what the page is: Concept, Guide, Runbook, Reference, Decision, Decision Record. The one field a concept page needs, and the only one lint requires |
title |
what a listing shows, and the page's backlink label |
description |
one line on what a reader gets here |
status |
draft or stable |
tags |
free-form; a flow list ([usage, ops]) or one - item a line |
stale_after |
a YYYY-MM-DD date after which the page reads as stale |
okf_version |
the bundle format, and only /fs/index.md may carry it |
verified |
{by, at} entries the human's review appends |
sources |
{title, resource} entries a promote adds |
Any other top-level key is kept as a custom key and ignored, so a page can carry
fields of its own. The block opens when the page's first line is exactly ---,
never --- # comment or ---yaml, and holds plain key: value lines; a | or
> block scalar is read as one folded line.
---
type: Runbook
title: Deploy the hub
description: How the hub is deployed on the node
status: draft
tags: [usage, ops]
stale_after: 2027-01-01
---
# Deploy the hub
A write is lenient. A page with no block is stored as it was sent, and the result
carries a missing_frontmatter finding that names the minimum to add, so nothing
is lost to a missing field and no page is silently untyped.
Paths
A page path is /fs/<directories>/<name>. In a URL the leading slash is dropped
and the namespace may be left out, so pages/svc/caddy.md and
pages/fs/svc/caddy.md both name /fs/svc/caddy.md. A first component of kv
is the key-value namespace, which a knowledge base does not have.
Every path is made canonical before anything keys on it: ., .., an empty
component and a trailing slash resolve the way a filesystem resolves them, and
.. never climbs above /fs. The store, the write log, the search corpus and
the link graph all use the canonical path, so several spellings name one page and
a response always carries the canonical one.
These are refused with invalid_argument, identically over REST and the agent
tools: a /kv path, a control character (NUL, a line break and a tab included),
a backslash, and a canonical path over 512 bytes.
Limits
| Limit | Value |
|---|---|
| One page | 1 MiB |
| One page path | 512 bytes |
| One JSON request body | 4 MiB |
| One knowledge base file | 1 GiB; a write past 256 MiB carries a warning |
| One history page | 200 rows, for the write log and for one page's versions |
| One listing | 500 entries |
| One folder export or import | 10,000 pages and 256 MiB of pages |
A session brain value keeps its own 4 MiB limit. The page limit applies only to the knowledge base.
Refusals
Every refusal is an application/problem+json document with the hub error
code. The admin gate runs before a request's body is read, so a caller without
the admin token gets 401 whatever body it sent (a query string that cannot be
read is still a 400 first), and an agent token is not the admin token.
| Status | code |
When |
|---|---|---|
| 400 | invalid_argument |
a refused path, a JSON body that does not parse or carries a field the route does not know, a frontmatter value containing a control character, a review or a promote of a page whose frontmatter cannot be patched safely (a first line such as --- # comment or ---yaml, which starts a block for some readers and not for the hub), verified_by naming anyone but the human, a history limit over 200, a revert body that does not parse, deleting a directory that still holds pages |
| 401 | unauthenticated |
no admin token, on every route |
| 404 | not_found |
the project, the page, the session or the session brain entry does not exist; a version the page's history does not name, or whose bytes were not kept or were forgotten; a history to forget for a path the log never named |
| 409 | conflict |
if_version does not match; the detail ends current_version=sha256:..., or current_version=absent |
| 413 | payload_too_large |
a page over 1 MiB or a body over its limit, refused before it is buffered whole; nothing is written |
| 415 | invalid_argument |
a page sent as anything but JSON, text/markdown or text/plain |
Routes
GET /api/v1/projects/{id}/kb/pages
Query: prefix, limit, meta. Returns {entries[], truncated}. truncated
is true when the listing held more than limit entries (500 at most).
Without meta the listing is one directory level. Each entry is
{path, type, size_bytes, children} where type is file or dir, children
is the number of entries directly inside a directory and null on a file, and a
directory's size_bytes is the sum of the files directly inside it.
With meta=1 the listing is every page and directory under the prefix, in path
order, so a tree, a directory view and the needs-review queue each cost one
request. A directory row is the same four fields. A page row adds title,
description, page_type, status, tags, trust, verified_by,
verified_at, stale, stale_after, last_write_by and last_write_at. The
frontmatter fields are null when the page has no frontmatter the hub's reader
can read.
trust is derived, never stored: unverified with no verified entry,
human_reviewed when the newest verified.by is human, machine_confirmed
when it is anything else, and edited_since_review when the page no longer holds
the bytes that were verified.
The write log decides that, not a clock. When a write lands, the hub notes on its
log row whether it brought in the page's newest verification: the page it stores
ends its verified block with an entry the page it replaces did not hold,
stamped within five minutes of the write landing. A verification is about the
bytes its author saw, so an older entry, taken out and put back over an edited
body or copied onto another page, was made for other bytes and brings nothing in.
A review always does. The page is edited_since_review when the version of its
newest write is not the version that write stored. So a page an agent writes with
a verified block of its own is not an edit since it, whatever second it lands
in; a retry that stores the same bytes is no edit; an edit in the same second as
the review is one; putting the verified bytes back reads as verified again, until
a later write brings in a verification of its own; and taking the newest entry
away to show an older one is an edit. A page whose log holds no such row, written
by a hub from before the rows carried the note, is judged as it was then: edited
when its newest write is later than the newest verified.at, to the second.
GET /api/v1/projects/{id}/kb/pages/{path}
Query: version. Returns {path, content, version, size_bytes, last_write: {actor, at}}. version is the token the next conditional write passes back.
last_write is the newest row for the path in the write log, however old.
With version, the response is that version of the page instead,
{path, content, version, size_bytes, current, at, actor}, where current says
whether it is what the page holds now and at and actor are the newest write
of those bytes. The version must be one the page's own history names, and a
deleted page can be read this way. A version whose bytes were not kept, or whose
history was forgotten, is not_found.
PUT /api/v1/projects/{id}/kb/pages/{path}
Body: {content, if_version?} as application/json, or the page itself as
text/markdown or text/plain with the guard in ?if_version=. A body sent as
JSON that does not parse is a 400 and is never stored as the page. if_version
is a version token, or absent to create a page only when nothing is there.
Without it the last writer wins.
Returns {ok, path, version, size_bytes, lint[], warnings[]}. lint is advisory
and never fails a write: missing_frontmatter, missing_type,
unparsed_frontmatter, okf_version_misplaced, link_escapes_bundle, and
broken_link for a link whose target is not a page anywhere in the tree. A page
with no block at all is missing_frontmatter, and its message carries the
minimum to add (a block opening with --- that sets type, or okf_version on
the root); a page whose first line only looks like the opener (--- # comment,
---yaml) or whose block cannot be read safely is unparsed_frontmatter, and a
review or a promote of it is refused until the first line is exactly ---. The
hub stores the bytes as sent.
DELETE /api/v1/projects/{id}/kb/pages/{path}
Query: if_version. Returns {ok, path}. Deleting a page that does not exist is
the same 404 a read of it gives: nothing is logged, nothing is signalled and no
knowledge base file is created. A directory can be deleted once it is empty. A
delete appends one kb_deleted signal to the project feed. The page's bytes stay
in its history, where a revert restores them and anyone who can read the project
can read them, until its history is forgotten with DELETE kb/versions.
POST /api/v1/projects/{id}/kb/pages/{path}/review
Body, all optional: {if_version?, verified_by?}. The review appends
{by: human, at: <now>} to the page's verified block and changes no other
byte. The reviewer is set by the hub: verified_by is accepted only as the
literal human and is otherwise a 400. if_version is the version the human
read; when the page changed since, the review is a 409 and nothing is stamped.
Returns {ok, path, version}, logs one kb.review row with the actor human,
and appends one kb_reviewed signal.
POST /api/v1/projects/{id}/kb/pages/{path}/revert
Body: {version, if_version?}. Writes the bytes of version, one the page's
history names, back to the page as a new write by the human, logged as
kb.revert. if_version guards it as it guards a put: the version the reader
saw as current, or absent to restore a deleted page only while it is still
gone. Returns {ok, path, version, size_bytes, lint[], warnings[]} and appends
one kb_reverted signal. A revert removes nothing from the history, so the
version it replaced can be reverted to in turn. A revert to the version the page
already holds writes nothing, logs nothing and signals nothing; its result says
so with changed: false, where a revert that wrote is changed: true.
POST /api/v1/projects/{id}/kb/promote
Body: {from_session_id, from_path, to_path, type?, title?, description?, tags?, if_version?}. Copies a session brain entry into the knowledge base, patches the
frontmatter with the fields given and adds a sources entry
{title: "<session name> brain <from_path>", resource: "agenthub://session/<session_id>/brain<from_path>"}. The source entry is left as
it was. This is the same code path as the brain_promote tool, so the page is
byte for byte the one an agent's promote writes. Returns {ok, path, version, lint[]}, logs one kb.promote row and appends one kb_promoted signal.
GET /api/v1/projects/{id}/kb/history
Query: path, prefix, actor, op, before, limit. Returns
{rows[], total, next_before, truncated}, newest first. A row is
{op, path, actor, at, version} where op is kb.put, kb.delete, kb.review
or kb.promote. prefix is a directory. limit defaults to 50 and is at most
200; limit=0 returns the count alone. next_before is the cursor for the next
page and truncated is true whenever the filter matches rows the response does
not carry.
This is the whole base's write log. One page's versions, with their bytes, are
kb/versions. The log is bounded only by the knowledge base file's own 1 GiB
limit and every request scans all of it, so total is the real count and a page
never loses its history or its last writer by being old. op may also be
kb.revert.
GET /api/v1/projects/{id}/kb/versions
Query: path, before, limit. Returns {path, current_version, versions[], total, next_before, truncated}, newest first. A version row is {id, op, actor, at, version, size_bytes, summary, kept, current}: id is the log row and the
before cursor, version and size_bytes are null on a delete, summary
says what the write did (created, edited, reviewed, promoted from a session brain, deleted, restored the version of <time>, or restored an earlier version when the version it restored has no earlier write in the log to
date it by), kept is whether its bytes can be read and restored, and current
marks the newest write of what the page holds now. current_version is null
for a page that is not there, which keeps its history. limit defaults to 50 and
is at most 200.
Every write and delete keeps the bytes it replaces and the bytes it stores in the
knowledge base file itself, so every version written since the hub kept versions
reads back; a version a hub from before that replaced is listed with kept: false. Nothing expires: the versions are bounded by the file's 1 GiB limit until
the operator forgets them. The limit is held against the pages the file uses, so
space a purge frees counts at once, and a write is checked at its own size plus a
copy of each version it keeps that the file does not already hold. A delete is
never refused for the copy it keeps, at most one page, so a full knowledge base
can still shed a page. The decision is
ADR 0029.
DELETE /api/v1/projects/{id}/kb/versions
Query: path. Admin only, like prune. Forgets the kept bytes of one page's
history: every version its log names except the one the page holds now, and all
of them for a deleted page. Returns {ok, path, versions_forgotten, bytes_forgotten, unreferenced_forgotten}. It also sweeps kept versions that no
log row of any page names, which only a crash between keeping a write's bytes and
logging it leaves, and counts them in unreferenced_forgotten. The rows stay, so
the history still says who wrote what and when, and they read kept: false; a
version read or a revert of one is then not_found, and a deleted page can no
longer be restored. A version whose bytes another page's history still names
stays for that page and is not counted, but no longer reads back through this
one. Writes after the purge keep their versions as before.
The purge is recorded as a system event by the human with the payload
{action: "kb_history_forgotten", path, versions, bytes, unreferenced_versions, unreferenced_bytes}, which no agent reads. It is appended before anything is
removed, so a purge that cannot be recorded removes nothing, and the removals and
the mark land together or not at all; a purge that rolls back after its audit
appends a second event, kb_history_forget_rolled_back, with the same counts.
The freed space is reused by later writes rather than given back to the disk, and
the engine does not zero freed pages, so the bytes can stay on disk until they
are overwritten; a backup taken before the purge still holds them. A path the log
has never named is not_found.
GET /api/v1/projects/{id}/kb/backlinks
Query: path. Returns [{path, title}], the pages that link to the page, in
path order. A page that links to itself is not listed, and such a link does not
save the page from orphan_page either.
A link inside code is not a link, here or in lint. Code is a fenced block (a fence closes only on a line of the same character, at least as long, with nothing after it, so a longer fence holds a shorter one), a block indented four spaces or a tab after a blank line outside a list, and a code span of any number of backticks. Inside a list an indented line is read as prose, so an indented code block nested in a list item still counts; fence it instead.
GET /api/v1/projects/{id}/kb/lint
Query: fresh. Returns {checked_at, findings[]} for the whole tree: the
per-write codes plus missing_index, missing_log, orphan_page and
missing_index_entry. A finding is {code, path, message, line?, meta?}; meta
is the page's meta=1 row when the finding names a page that exists.
checked_at is when the tree was walked, not when it was asked for. fresh=1
walks again.
GET /api/v1/projects/{id}/kb/stats
Returns {pages, bytes, unverified, stale, last_change_at, last_change_by, needs_review: {unverified, machine_confirmed, edited_since_review, total}}.
Derived numbers
The meta=1 listing, backlinks, lint and stats come from one walk of the tree,
kept per project. Every write, from either surface, invalidates it, so a read
straight after a write is current. An entry is also dropped after ten seconds.
Sync with a folder
agent-hub kb export and agent-hub kb import move a whole knowledge base to a
plain folder and back, so an operator can edit it in their own editor, diff it or
keep it in git. Both are client commands: they read HUB_URL, HUB_TOKEN and
HUB_PROJECT (or --project) like the rest of agent-hub kb, and go through the
same agent tools on one connection. The pages land only in the directory named.
The decision is ADR 0030.
agent-hub kb export --dir ./kb # every page, at its path under /fs
$EDITOR ./kb/runbooks/deploy.md
agent-hub kb import --dir ./kb --dry-run # what would change, nothing written
agent-hub kb import --dir ./kb # write the pages that changed
Export
kb export --dir D writes each page to the file at its path under /fs, so
/fs/runbooks/deploy.md is D/runbooks/deploy.md, and writes the manifest
D/.agent-hub-kb.json:
{
"format": 1,
"project": "homelab",
"pages": {
"/fs/index.md": "sha256:...",
"/fs/runbooks/deploy.md": "sha256:..."
}
}
A directory that holds anything is refused. --force writes into it anyway: it
writes over the page files and the manifest, and removes the file of each page
the old manifest names that the hub no longer has, printing removed <path> for
it, so the next import does not bring the page back. Only a regular file reached
through plain directories inside D is removed; a directory left empty stays, and
nothing the old manifest does not name is touched. A manifest that cannot be
read, or one exported from another project, refuses the export.
Every page is read, and every target checked, before D is created or the first file written: a path that would leave the folder, a symbolic link on the way to a page, or a directory where a page goes refuses the export. Each file is then opened without following a symbolic link, so one put in place after the check is refused rather than written through. A page whose path has a dot-named component is left out with a note on stderr, because an import does not read it back.
Two pages whose paths differ only in case or Unicode normalisation, in a file
name or a directory on the way to one (Notes.md and notes.md, Runbooks/ and
runbooks/), refuse the export. A filesystem that ignores case or
normalisation, as macOS does by default, keeps them as one file, and the import
after it would write the survivor to the wrong page. Rename one on the hub first.
The check folds case with Unicode lowercasing after NFC normalisation, which is
close to what such a filesystem does but not the same in every script.
Import
kb import --dir D reads every file under D except dot-named files and
directories, which keeps the manifest and a .git directory out of the bundle. A
symbolic link, a file that is not UTF-8 text, a page over 1 MiB, a path the hub
would refuse, two files whose paths differ only in case or normalisation, and a
folder over the bundle limits are refused before anything is written. A file name
is read as its NFC spelling, which is how an export wrote it, so a name macOS
hands back decomposed is the same page. Once the hub is listed, a folder page
that differs only in case or normalisation from a hub page the import keeps is
refused too: a rename that changes only case is then one page again only with
--prune, which deletes the old spelling. Files are opened without following a
symbolic link. A manifest exported from another project is refused too, and so is
one that names a page twice or names a path that is not a canonical page path
under /fs.
The import then plans every change against the hub and lints the folder whole
with the hub's own lint. These findings refuse the import with nothing written
when they are on a page it would create or update: unparsed_frontmatter,
okf_version_misplaced and link_escapes_bundle. On a page the import leaves as
it is, the hub already holds them, so they are printed as warnings and do not
refuse. The rest (missing_frontmatter, missing_type, broken_link,
missing_index, missing_log, orphan_page, missing_index_entry) say a
bundle is incomplete. Each warning is one line on stderr, agent-hub: lint <code> <path>:<line>: <message>, or without :<line> for a finding about the whole
page, and the import goes ahead, so a knowledge base whose pages already carry a
broken link still imports its own export back.
Each page is compared by version in the folder, on the hub and in the manifest, and gets one line on stdout:
| Line | When | Written |
|---|---|---|
| none | the folder and the hub hold the same bytes | no |
create <path> |
the folder has a page the hub and the export do not | with if_version: "absent" |
update <path> |
the folder changed the page and the hub did not | with the manifest's version |
hub newer <path>: left as is |
the folder holds the page as exported and the hub changed or deleted it since | no |
delete <path> |
with --prune, the export had the page and the folder does not |
with the manifest's version |
keep <path>: ... |
without --prune, the export had the page and the folder does not |
no |
conflict <path>: ... |
the folder changed the page and the hub changed, deleted or created it too; with --prune, the folder removed a page the hub changed |
no |
delete <path>: already gone |
with --prune, the hub deleted the page while the import ran |
no |
not attempted <path> |
the hub refused an earlier change for a reason other than a conflict | no |
A conflict is skipped and every other change is still made. A page that changes
on the hub while the import runs is refused by its guard and reported as the same
conflict. Any other refusal from the hub stops the import there: the lines above
it are what was made, the page it refused and every later write or delete read
not attempted, and the hub's error follows on stderr with its exit code. A
manifest that cannot be saved once the hub was written is reported the same way,
after the lines for what was made. A last line counts each kind. The import then
rewrites the manifest with the version of every page it wrote or found in step,
and drops each page that is gone from both the folder and the hub, so a second
import of the same folder changes nothing. A hub newer page keeps the version it
was exported at; the next export brings the hub's copy and its version into the
folder. A folder with no manifest is taken as a new bundle: every page in it is a
create, and one the hub already has with other bytes is a conflict.
--prune deletes only pages the manifest names, so a page created on the hub
after the export is never pruned. --dry-run prints the same lines, with
create, update and delete as would create, would update and
would delete, and writes nothing, the manifest included.
The exit code is 0 when every change was made, 1 when any page was a conflict (on
a dry run too, so a script can check first), when the lint refused the folder, or
when a file refused, the hub's own code when it stopped the import, 2 for a usage
mistake, and the client codes agent-hub kb already uses for an unreachable hub
(69), a refused token (77) and no hub configured (78).
There is no merge. To resolve a conflict, export again into a fresh folder and
carry the change across, or write the page with agent-hub kb put.
Agent tools
brain_get, brain_put, brain_list and brain_delete reach the knowledge
base with store: "project", under the project's own read and write grants.
brain_promote(from_path, to_path, project_id?, type?, title?, description?, tags?, if_version?) reads from_path from the caller's active session brain and
returns {ok, path, version, lint[]}. A brain_delete of a page that does not
exist is not_found.
brain_history(path, project_id?, before?, limit?) lists a page's versions as
kb/versions does, brain_get with store: "project" and version reads one
with at and actor, the newest write of those bytes, and
brain_revert(path, version, project_id?, if_version?) puts one back, deleted
pages included, returning {ok, path, store, version, size_bytes, lint[], warnings[], changed}. History reads need the project's read access and a revert
its write access, the same as any read and write. Forgetting a history is not an
agent tool. Every page an agent may read is also an MCP resource; the
Resources section has the URIs.
What there is not
There is no move or rename. A move is a read, a write with if_version: "absent" and a delete, by the client. The new path starts a history of its own,
since history is keyed by path; the old path keeps its history, and links to the
old path are not rewritten: backlinks names them first. Wiki links ([[page]])
are not links to the hub; only markdown links are followed.
Offline reading for the knowledge base is to be evaluated. Today what is cached by the service worker is the application shell and its static assets; page content is not cached, and reading pages requires an active connection to the hub.
See also
- MCP tools - the brain tools and resources
- CLI - the
kbshorthand and its exit codes - Guides - the human's wiki workflow
- Data model - the knowledge base file