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)
The message as read out of the binary on 2026-08-30 and quoted in the worklist of that date, reports/2026-08-29-first-integration.md; the string landed in commit c071dd4. The path is elided as the report elided it. Wrapped here to fit the plate; the binary prints one line.

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)" }
The refusal shape, with the field names in the order 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.

$ export KB_MISSES_PATH=/tmp/kb-misses.txt
The second option, as the kb README gives it at commit 8285c1f, read on 2 September 2026. No output: the path the log went to is reported back in 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
Row one is benchmarks/latency/RESULTS.md, commit 35322d2. Row two is the worklist of 2026-08-30, timed from Python's subprocess.run with output discarded, landed in commit c071dd4. Row three is the first integrator's field report, which is not in the repository, and nobody here has reproduced it: the WSL on the project machine has no Rust toolchain.
$ 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 command timed for row two and its four numbers, as the worklist of 2026-08-30 records them (commit c071dd4). The command is longer than the plate and scrolls.

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.

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
Two lines of .github/workflows/release.yml, as committed in cbd590d on 2026-08-28; the step ran in CI the same day. Each line is longer than the plate and scrolls.

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 }] }]
}
The field names, in the order 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 example as tools/kb/README.md gives it at commit 8285c1f, read on 2 September 2026, re-wrapped to the plate; a documented shape, not a pasted run. Lines longer than the plate scroll.

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.

Previous: On Your Machine · Next: Reference · All of the docs · Back to the library