Lenses
A lens is a named federation of one write repo plus N read mounts, presented to a client as one virtual knowledge base. A query through a lens fans out to every mount and returns a single merged, deduplicated result set; a write through a lens lands in exactly one repo. Everything else in knomit — facts, branches, provenance, sync — is unchanged underneath.
What a lens is made of
Section titled “What a lens is made of”| Field | Meaning |
|---|---|
name | Lens name. Matches [a-z0-9_-]+, and shares one namespace with repo names. |
write | The write repo, by per-machine repo name. |
description | Free markdown, display-only. Capped at 4096 bytes. |
reads[] | The read mounts: repo, optional branch, optional source. |
On a read mount, branch: "" means “that repo’s agent branch, resolved at bind
time” — the lens does not freeze it. source is an optional src:// slug
surfaced in provenance metadata.
The write repo is always also a read mount. A lens definition is normalized before it is stored: reads are deduped by repo (first occurrence wins, so an explicit entry for the write repo keeps its configured branch), the write repo is appended if absent, and the result is sorted by repo name. That sorted list is the binding order referred to throughout this page.
Writes always go to the write repo’s agent branch
Section titled “Writes always go to the write repo’s agent branch”There is deliberately no write-branch field. The branch a mount is read at
and the branch writes land on are two different things: writes always commit
to the write repo’s own agent branch (agent/<hostname>-<fingerprint8>), exactly
as they would through that repo’s own endpoint. A lens changes what you can see,
never where you write.
knomit_repos reports both: the branch column is the read branch, and
write_branch — present only on the read+write mount — is where writes commit.
Defining a lens
Section titled “Defining a lens”Create a lens with POST /api/v1/lenses (or from the web UI). The definition is
validated against the live repo set, and the same rule set is re-validated on
every PATCH:
| Rule | Status |
|---|---|
Name must be non-empty and match [a-z0-9_-]+ | 400 |
| A write repo is required | 400 |
| Name must not collide with an existing repo name | 409 |
| Name must not collide with an existing lens | 409 |
| A create for the same name must not already be in flight | 409 |
| Two mounts’ repo IDs must not share a 12-hex prefix | 409 |
| Every member repo must be registered | 422 |
| A pinned branch must exist in its member repo | 422 |
| Description must be ≤ 4096 bytes | 422 |
Three consequences worth stating plainly:
- Repo and lens names share one namespace. A lens cannot take a name a repo already has, and vice versa. (They share it because a lens and a lens-of-one repo both surface the same identity for cursor pinning.)
- The prefix rule is about addressing, not duplication.
kb://<id>/…routes on a 12-hex prefix of the root commit, so two members sharing that prefix would be misrouted — which is why the check is on the prefix rather than the full ID. It cannot fire for a repo and its own clone: at most one active repo may hold a given knowledge base, and a lens can only name active members, so two mounts of the same KB are unreachable by construction. What remains live is the rarer case — two genuinely different knowledge bases whose root commits happen to collide in their first 12 hex digits. - A repo referenced by a lens cannot be archived or purged. Delete the lens
first.
DELETE /api/v1/lenses/{lens}removes the definition only and returns 204; member repos are untouched.
Addressing: kb://<id>/…
Section titled “Addressing: kb://<id>/…”A repo’s ID is its root-commit hash; the wire form is the first 12 hex chars. In lens results:
| Form | Means |
|---|---|
kb/technology/…/fact.md | A fact in the write repo. |
kb://a1b2c3d4e5f6/kb/technology/…/fact.md | A fact on the read mount with that 12-hex repo id. |
These qualified paths are stable identifiers. Pass one back as
knomit_explain’s file, or store one in a fact’s refs to cite across repos —
the server never rewrites it. A kb:// ref is classified as external, not a
local fact edge, so it contributes no evidence weight and produces no local
provenance edge (see Ref forms).
Call knomit_repos (no parameters) to get the mount table:
{ "binding": "work", "mounts": [ { "name": "notes", "id": "a1b2c3d4e5f6", "branch": "agent/box-9f2c", "role": "read", "source": "src://notes" }, { "name": "core", "id": "0f1e2d3c4b5a", "branch": "main", "role": "read+write", "write_branch": "agent/box-9f2c" } ]}role is read or read+write. write_branch appears only on the write mount.
What a union read actually does
Section titled “What a union read actually does”A lens read is not a concatenation. Four rules govern it:
Relevance ordering uses Reciprocal Rank Fusion. Native similarity scores are
not comparable across mounts, so raw scores are discarded and each row is scored
1/(60 + rank) within its own mount’s list, then merged. Ties break by mount
order, so fusion is deterministic. With a single mount the output order is the
input order.
Recency ordering uses a k-way merge on committed_at. Commit timestamps
are comparable across mounts, so recency needs no fusion — rank fusion exists
for incomparable scores and would be wrong here.
Dedupe is by repo-relative path, and the write mount wins. When the same
kb/<topic>/<category>/<uuid>.md path exists on two mounts, the write repo’s
copy is emitted and the read-mount copy is dropped — even when the shadowed copy
ranks higher. Remaining collisions resolve in binding order. (This is not
hypothetical: a re-rooted fork of a read-mounted upstream has a different
root-commit ID, so it mounts, but it shares the upstream’s fact UUIDs.)
Fan-out is ontology-aware. A bare path filter of the form kb/<topic>/ skips
mounts whose ontology lacks that topic — which is why a
shared topic vocabulary across the mounts is what makes cross-repo browsing
coherent. The topic must be fully delimited by a
trailing slash — kb/decisions/ constrains, kb/decisions is a prefix that
could also match kb/decisions-x/ and constrains nothing. The skip is an
internal optimization: a skipped mount is indistinguishable from one that matched
nothing.
Per-mount candidate depth is capped (500 rows per mount on the REST reads), and
the fused union is truncated to the requested limit last.
Using a lens
Section titled “Using a lens”Bridge
Section titled “Bridge”knomit-bridge --lens work--lens is mutually exclusive with an explicitly-set --repo. In lens mode the
bridge skips agent-branch discovery entirely — each mount resolves its own branch
server-side — and connects to <base>/api/v1/lenses/<lens>/mcp. See the
CLI reference.
Claude Code
Section titled “Claude Code”knomit-bridge claude init -lens workThe hooks read the binding out of .mcp.json and resolve the lens’s write repo
via GET /api/v1/lenses/{name}. On any failure — server down, unknown lens,
malformed response — they skip with reason lens_unresolved rather than falling
back to the directory basename. Hook reads are write-repo-scoped:
session-start context and post-edit lookups cover the write repo only, not the
union. See Claude Code.
Web UI
Section titled “Web UI”The context switcher has two groups, Repos and Lenses. In lens context the top
bar shows the mount count and a writes → <write repo> pill, a Sources
dropdown narrows the union to a subset of read mounts, and a repo: filter facet
is available (lens context only). Facts from read mounts carry a source badge and
are not editable there. See Web UI.
Through MCP
Section titled “Through MCP”Session instructions gain a lens addendum: the mounts table (repo · id ·
branch · role · source), a note that the branch column is the read branch while
writes go to the write repo’s agent branch, the kb:// convention with a worked
example, how to correct a read-mount fact, and read coverage — which topics each
mount has. For a lens of one the addendum is empty, so single-repo sessions are
byte-identical to before lenses existed.
Three behaviours differ from a repo binding:
knomit_updateandknomit_retractreject akb://<read-mount-id>/…path: “read-only mount: repo X is not this binding’s write repo — facts there can only be changed through their own endpoint.” Edit it through that repo’s own endpoint, or through a lens whose write repo it is.knomit_explainnever fans out. The fact’s own path fixes the mount and the entire provenance walk stays inside it. Akb://id that is not mounted returns “repo <id> is not mounted in this binding”.- Pagination cursors are pinned to their binding. A cursor is a frozen view of one binding’s read set; resuming it through a different binding is rejected, as is resuming after the lens’s mounts or branch pins changed.
See MCP tools.
REST surface
Section titled “REST surface”All paths are under /api/v1.
| Endpoint | Notes |
|---|---|
GET /lenses, POST /lenses | List and create. |
GET /lenses/{lens} | The definition — including write, which is what the Claude Code hooks resolve. |
PATCH /lenses/{lens} | Edit. Carries no name field — renaming has its own route. |
POST /lenses/{lens}/rename | Rename. A custom action rather than PATCH, because the response re-reads the lens through its new name and a rename invalidates the identifier the request was addressed by. Renaming to the current name is a successful no-op; 409 means the new name is held by another lens or by a repo — the two share one namespace. |
DELETE /lenses/{lens} | 204. Definition only; member repos untouched. |
GET /lenses/{lens}/facts, /facts/{path} | Union fact collection and single-fact read. |
GET /lenses/{lens}/search | Union relevance search. |
GET /lenses/{lens}/completions, /topics, /topics/{path} | Union metadata. |
GET /lenses/{lens}/stats | Union stats and activity roll-up, plus a per-mount repos[] breakdown. |
GET/POST/PUT/DELETE /lenses/{lens}/mcp | The lens MCP endpoint. |
On PATCH, an absent key keeps its current value and a present key replaces
wholesale: reads replaces as a set, it never merges. The full create rule
set is re-validated.
Points that differ from the repo endpoints:
- Lens read endpoints use a flat envelope, not the HAL collection envelope —
{facts, total},{results, total},{values}— mirroring the MCP query response. They carry no_links, because a lens has no single per-branch anchor.totalis the post-dedupe union size;limit/offsetpage within it. - A bare
kb/…path on the single-fact endpoint always reads the write repo, with no dedupe scan, even when a read mount shadows the same path. To target a mount, usekb://<id12>/kb/…, percent-encoded as one path segment. - Every not-found case on the single-fact endpoint returns a byte-identical
404 — unknown mount id, malformed
kb://, missing fact, retracted fact — so a caller cannot probe mount topology. repo=(repeatable) narrows the fan-out to named mounts; naming a non-mount is 422.completions?category=repois lens-only and is served from the lens definition alone: mount names in binding order, write repo first, prefix-filtered case-insensitively.statsreturnstotal,repo_count,last_commit,avg_confidence,domains,entities, andrepos[].avg_confidenceis a count-weighted mean across mounts, whiletotal,domains, andentitiesare summed per mount (not deduped). Empty maps serialize as{}, nevernull.repos[]is the per-mount breakdown —id,name,source,branch,is_write,total,avg_confidence,domains,entities,last_commit,changes_7d,changes_30d,changes_90d— in binding order. It is exact per mount, so consumers needing collision-free numbers should read it rather than the summed top-level fields.- In read-only mode, both MCP route shapes are exempt from the mutating-method gate, but lens CRUD stays gated.
See the REST API reference for the full parameter set.
Limits and gotchas
Section titled “Limits and gotchas”- Lens config is machine-local and unsynced — not in git, not part of the corpus, keyed by per-machine repo names. See Remote sync for what does travel.
- A bare path on the single-fact REST endpoint means the write repo, always. Shadowing does not apply there.
- On union reads, the write mount’s copy wins a path collision, even against a higher-ranked read-mount copy.
- One bad mount fails the whole read. There is no partial result and no coverage metadata.
PATCHcarries no name field — renaming is its own route (above) — andreadsonPATCHreplaces rather than merges.- Two mounts of the same underlying repo (a clone or replica sharing a root-commit ID) are rejected at create time.
- A repo cannot be archived or purged while a lens references it.