Somewhere That Is Not Your Machine
Last updated: 2 September 2026
Local first is a position
The library lives with its reader. kb reads the markdown on
every run, and the index it builds, the catalogue's card drawer, sits in
a .kb/ folder beside the books it was built from. Nothing in
it talks to a server. There is no hosted instance to point a client at,
and the base has to be on the same filesystem as the binary. That is a
position, recorded as one, and not a limitation waiting for a later
version to lift it.
It happens to describe a server as well as it describes a laptop. A function runtime that has the folder and the binary on one filesystem has everything the librarian needs. What it usually lacks is a writable disk, a repository, time to spare at process start, and a libc that matches the one the binary was built against, and those four are the whole of this page.
The rest of the position comes from the decision that set it, ADR-0008: build for exactly one self-hosted user. No accounts, no tenancy, no telemetry, and configuration is a file in the base. A hosted service is kept possible and is not built. What keeps it possible without building it is that the base is addressed by path and never assumed, and that nothing derived is ever the source of truth, so the files can be run anywhere and the index rebuilt from them after any failure.
The shape that reached production
The first deployment was not ours. An integrator put kb
inside a chat agent running in a serverless function: an ephemeral
instance, a read-only filesystem, the base and the binary shipped inside
the deployment bundle, and one execFile of
kb route --json per question. The binary was
kb-v0.1.0 for x86_64-unknown-linux-musl, built
at commit f320903. The base held 2 agents, 9 index entries
and 11 text files, 148 KB in all.
Their field report is dated 2026-08-29, and it is not in the repository because it names a real deployment, a real project and real questions asked by real people. What the repository holds is the mechanism half: a worklist written on 2026-08-30, before any code changed, so that each finding could be turned into a failing test first.
The read half held. Six real questions, zero retrieval failures, the verdict gate obeyed, and the error contract used to warn a person on screen. The write half was never exercised and in that shape could not have been, because every write path assumed a writable repository on the same machine as the question. A hosted consumer never has one.
Ten findings came out of the report. Nine closed on 2026-08-30, each one a failing test before a change. The tenth, F-08, is batch mode; it was never scheduled, it is open, and it blocks nobody. The four lessons below are those findings sorted by what a deployment has to know.
Lesson one: ship the base and its index, and mean --all if you pass it
A bundle needs no .git. It did once: kb asked
git ls-files which files it might serve, a bundle has no
repository, and so every base was left out and the result set came back
empty. The first integration passed --all to get past that,
which is the wrong reason to pass it. ADR-0034, accepted on 2026-09-01,
removed the question. The private layer is now a declaration in each
base, profile/, projects/ and
records/ unless agent.txt says otherwise, and
it is read off the files you shipped. Run that day:
examples/demo copied outside any repository, indexed and
routed without --all, three agents served.
So --all means what its help text always said, include the
private layer, and nothing else. Pass it only if the consumer is meant to
see that layer. A hosted agent answering other people almost never is.
The catalogue has to travel too. kb index builds the
.kb/ folder inside each base, at
<base>/.kb/index.db, and a runtime that cannot write
cannot build it, so build it before the deploy and make sure it reaches
the machine that answers the question. A bundle that left
.kb/ behind used to get
cannot open the index: Permission denied (os error 13),
which sent the integrator to look at file permissions. Since F-07 the
message names the cause before the symptom.
cannot open the index at <path>: nothing has been
indexed here yet. Run `kb index` on the base, and make
sure the .kb/ directory it writes reaches the machine
that answers the question. (os error 183)
With --json the same sentence arrives as the
error field of {"question": ..., "error": ...}
on stdout, with exit code 1. An index that exists and will not open gets
the reason and none of that advice, because building one is not the fix.
Lesson two: a read-only filesystem is survivable, the miss log is not free
The index is built before the deploy and only read at runtime, so a
read-only filesystem answers correctly and does not try to reindex. The
one file a query writes is kb-misses.txt, the librarian's
record of the books she was asked for and did not have: the recall loss
log. A failed write there prints one line on stderr, returns the reason
in miss.error, and does not fail the query. That was run on
2026-08-30 against a log path that could not be written: exit 0,
miss.recorded false, miss.error carrying
os error 5. It was not run against a genuinely read-only
mount, which is a different thing, and the kb README marks it so.
"miss": { "question": "...",
"looked_like": ["..."],
"date": "...",
"log": "...",
"recorded": false,
"error": "... (os error 5)" }
route_payload writes them and the values elided; not a
pasted run. recorded false and os error 5 are what the
unwritable-path run of 2026-08-30 returned, landed in commit c071dd4.
Losing that log costs more than it appears to. It is the only record of what the base failed to answer, and the first deployment ran its window of six real questions and kept none of them, while the log in the repository held two lines written earlier on a laptop. Two options, and they are not the same thing.
-
Read
missout ofkb route --jsonand store it where your stack already writes. The object is self contained for this purpose. This survives the instance. -
Export
KB_MISSES_PATH=/tmp/kb-misses.txtand accept that the log lives as long as the instance does. This costs one line of configuration and keeps the file's own shape, and it is gone when the instance is.
$ export KB_MISSES_PATH=/tmp/kb-misses.txt
miss.log.
KB_MISSES_PATH names a file and not a directory, so nothing
is guessed; two fleets pointed at one path share one log, and that is
the caller's decision to make. An empty value is treated as unset,
because a platform that exported the name and chose no value has not
chosen a path.
The writes that change the base itself, kb write and
kb promote, do not belong on that machine at all. What a
machine without the repository can still do on the write side is the
section but one below.
Lesson three: every process start pays the cold open
Every process start pays the cold open: the binary starts, the fleet is opened, the catalogue is read, and only then is the first question answered. What that costs depends on the operating system far more than on the base. Three numbers, each with its conditions. Two were run here; the one that matters most for a deployment was not, and is marked.
| Run | Conditions | Date | Measure | ms | Beside it |
|---|---|---|---|---|---|
| Open the fleet and answer the first question, in process | Windows laptop, i5-1135G7, 16 GB; examples/demo; release build | 2026-09-02 | cold open, once | 11.8 | then warm p50 1.16 and p95 2.16 over 1000 samples of 50 questions; the same run on the 2026-08-23 code measured a 184.6 ms cold open, and git leaving the runtime is the difference |
Spawn kb route --json, open, answer |
the same laptop; examples/demo; release build; 40 samples after 3 warm-ups | 2026-08-30 | p50 | 184.8 | min 145.8, p90 252.2 |
| The same spawn and answer | Linux, WSL2 on x86-64; a 9 entry base; 40 executions | 2026-08-29 | p50 | 9.6 | about 6 ms of it process creation; the integrator's figure, not reproduced here |
$ kb route "como faco rollback sem downtime" examples/demo --all --hybrid --json --top 4
p50 184.8 ms min 145.8 p90 252.2 max 308.7
The gap between the second row and the third is about twentyfold, and it is why the earlier advice was wrong. The kb README used to say that spawning per request "pays the 136 ms every time", quoting the first row's cold open as if it were the cost of a spawn. That is a Windows in-process number generalised to Linux, where the whole spawn and answer costs less than the rounding, and the sentence pushed people toward a long-lived process they did not need.
The corrected advice. On Linux, do not engineer around the spawn: a few
lines of execFile per request, and the process costs less
than one round trip to anything. On Windows, and in a loop on any
platform, measure before you decide. kb serve speaks MCP
over stdio and pays the open once per process, which fits a long-lived
worker and does not fit a stateless HTTP handler.
Careful. The warm figures in row one are a personal fleet at personal-fleet scale, 15 files. The LongMemEval-V2 pre-reader instrument, measured 2026-08-26 on a laptop of the same specification (i5-1135G7, 16 GB, Windows 11) over haystacks of about 200 MB each, has a query latency of p50 745 ms on the enterprise domain and 773 ms on the web domain. Those are different regimes, and they must not be set beside each other as one speed.
Lesson four: match the libc, or link none
A Linux binary built against a modern glibc will not start on the older glibc that serverless images carry, because glibc symbol references are versioned. A build on Ubuntu 24.04 asks for glibc 2.39; Amazon Linux 2023 has 2.34, and Amazon Linux 2 has 2.26. The failure is invisible until the deploy: the build goes green, uploads, and dies on the host with a missing symbol version.
The release workflow builds x86_64-unknown-linux-musl
instead, which needs musl-gcc on the runner because the one
dependency compiles bundled SQLite with a C compiler. file
reports the artifact as static-pie linked, stripped. The same artifact
then runs kb check --all examples/demo inside
amazonlinux:2023 and inside alpine:3 before
anything is published, so the portability claim is executed rather than
asserted. That ran in CI on 2026-08-28.
docker run --rm -v "$PWD:/w" -w /w amazonlinux:2023 "./$KB" check --all examples/demo
docker run --rm -v "$PWD:/w" -w /w alpine:3 "./$KB" check --all examples/demo
Four binaries are built beside each other, each with a
.sha256 next to it: kb-linux-x64,
kb-windows-x64.exe, and since
kb-v0.2.2 kb-macos-arm64 and
kb-macos-x64. macOS was absent until then for a stated
reason, that nobody on the project runs one, so a published artifact
would have been one nothing had ever executed. The matrix answers that
reason instead of overruling it: x64 is built on an Intel runner and
arm64 on an Apple silicon one, natively, and each binary prints its
version and lints the demo fleet on the architecture it claims before
anything is published.
Note. Every binary is on the latest release, and On Your Machine shows the download, the checksum and the provenance check. Take the latest rather than a pinned tag: kb-v0.1.0 predates ADR-0034 and still asks git which files it may serve, so on a bundle with no .git it leaves every base out and answers with nothing. That is the exact deployment this page describes. After replacing a deployed binary, run kb index before anything reads. A binary whose index predates a column it needs empties that index when it opens it, and only kb index fills it again, so a read in between is served by an empty base and looks like a base that does not cover the subject. The source is public at github.com/richard-wollyce/ulpia, under Apache 2.0, and can be cloned today.
Reading the JSON
kb route --json prints one line on stdout, and it is not a
serialisation of the terminal output. A program wants the ranked list,
the passages and the verdict in one call, which is what the contract
computes in one pass anyway, so --json always fuses both
scorers and --hybrid adds nothing on top of it.
{
"question": "...",
"verdict": "hit",
"gate": { "served": true,
"ranked_by_text_only": false,
"floor": ... },
"confidence": { "agreement": ...,
"keyword_score": ...,
"margin": ... },
"agent": { "name": "...", "score": ...,
"files": ..., "margin": ...,
"contenders": ..., "totals": [ ... ] },
"keyword_top": "...",
"indexed": { "entries": ..., "agents": ...,
"aliases": ... },
"skipped": [],
"index_was_rebuilt": false,
"suggestions": [],
"miss": null,
"results": [{ "base": "...", "path": "...",
"memory": "long",
"title": "...", "purpose": "...",
"score": ..., "keyword_score": ...,
"why": ["keywords #1", "text #1"],
"matched": ["..."],
"passages": [{ "heading_path": "...",
"text": "...",
"excerpt": "...",
"provenance": "...",
"stage": null }] }]
}
route_payload and
retrieved_as_json in tools/kb/src/main.rs write them at
commit ea43d1d, read on 2 September 2026, with the values elided. A
shape, not a pasted run.
Careful. Branch on gate.served, never on results.length. A refusal still carries its candidates, because the verdict comes from the keyword scorer, the catalogue's reading of the labels, and the results come from both scorers, so verdict: "nothing" with a full array is an ordinary outcome and not a contradiction.
gate.ranked_by_text_only separates the two refusals a
caller has to handle differently. true means the text
scorer found files the labels missed, which is a base whose
Search for lines need a word and which may well hold the answer.
false with an empty results means nothing
ranked anywhere, which is a coverage problem. gate.floor is
what confidence.keyword_score was measured against for this
corpus, never the calibration constant, so the gate can be argued with
rather than guessed at.
skipped names bases that were left out, on stdout beside
the results rather than on stderr. It is empty since git left the
runtime, because the one reason a base used to be left out no longer
exists; the field stays because callers read it, and it is where a
future reason will be named. The integrator used it to tell "the base
does not cover this" from "no base was searched" and showed a warning
on screen.
results[].memory is short or
long. Short is the deposit, inbox/: recent,
unjudged, not yet in the library, served on purpose and labelled on
purpose. A caller that wants only settled knowledge filters on
long.
miss is null when the answer was served. On a
refusal it is the self-contained object shown under lesson two, and a
caller with nowhere to write persists it whole. Errors go to stdout as
{"question": ..., "error": ...} with exit code 1, and to
stderr as the same sentence a person would read.
Nothing that existed was removed or renamed when gate and
miss were added on 2026-08-30. A test in
main.rs lists the payload's field names and fails when one
leaves it, because a missing key and a key holding null
read the same way in most languages. Field by field, the payload is in
the Reference.
The write side from a machine that has no repository
Every write path but one assumes a repository on disk with permission to
write. kb remember assumes none of it. It measures a claim
against what the base already says and proposes one of three outcomes;
it writes nothing, decides nothing and needs no model, so it can run at
the moment a fact appears, in the conversation, on the machine that has
none of the repository.
The measure is containment: the fraction of the claim's words already
present in one chunk. Not Jaccard, which divides by the union and so
scores a short claim low however completely a long section already
covers it. At 0.90 or above the proposal is NOOP, the base
already says this. At 0.55 or above it is UPDATE,
substantial overlap with a small difference, which is what the same fact
with a new value looks like. Below that, ADD.
DELETE is never proposed, and the tool says so on every
run. Deleting needs the claim to be false rather than absent, and no
count of shared words can tell those apart. The pipeline ADR-0007
defined has four outcomes and lets the agent delete on its own, with the
reason in the commit message; that is judgement, and judgement is what
this command declines to exercise.
--json emits claim, proposal,
reason, evidence[] and notice;
each evidence entry carries base, path,
heading_path, excerpt,
containment, shared and
missing. The flag was accepted and ignored on this command
until F-04; it reaches it now. All three outcomes ran against
examples/demo on 2026-08-30: NOOP at
containment 1.000, UPDATE at 0.625, ADD at
0.250. Errors take the shape route uses,
{"claim": ..., "error": ...} on stdout with exit 1.
$ kb remember "the rollback path is written down before the deploy" fleet/zed --json
{
"claim": "the rollback path is written down before the deploy",
"proposal": "NOOP",
"reason": "5 of 5 words are already present in one chunk. ...",
"evidence": [{ "base": "zed",
"path": "knowledge/deploy-checklist.md",
"heading_path": "...", "excerpt": "...",
"containment": 1.0,
"shared": ["rollback", "..."],
"missing": [] }],
"notice": "kb measured overlap. Whether the older text is now wrong is judgement ..."
}
The pattern for a hosted consumer is to ask at the moment, queue the
proposal, and apply it later. Call
kb remember <claim> --json where the fact appears.
Store the object; it is self contained and stable, so it can be queued,
versioned and reviewed. On a machine that has the repository, act on it:
ADD becomes kb write <agent> <slug> --keys ...
with the claim as the body, UPDATE means edit
evidence[0].path in evidence[0].base, the
passage the overlap was measured against, and NOOP is
dropped. notice carries the same caveat the terminal
prints, so a model reading this through another surface is told what a
person is told.
Over MCP, kb_remember is the same judgement for a
long-lived process. kb serve exposes it beside
kb_route, kb_retrieve and
kb_fleet, all read only: it proposes and writes nothing,
and there is deliberately no write tool a model can reach.
Left open on purpose
Three things this page does not settle, because each is a decision and not a task.
-
Whether a
guessis a recall loss. Today it is not: a guess was served, with a warning, and a question that reached the caller is not one the base failed to reach. Moving that line changes whatkb-misses.txtcounts, and both live revisit triggers are measured against that file, so it moves on a measurement. A test pins the current answer so that changing it has to be deliberate. - Batch mode, F-08. Many questions in one invocation and one JSON line per answer is the cheap version of a server, and worth a decision rather than a reflex. Open.
-
A per-consumer scope. ADR-0034 gives two: everything, and everything
outside the private folders. A hosted agent that may read
projects/and notprofile/needs a third, per consumer rather than per base. It is built when the second real consumer asks, and not before.
Previous: On Your Machine · Next: Reference · All of the docs · Back to the library