feat: the daemon serves an HTTP API, and each room asks for what it shows - #64
Open
ceounittt wants to merge 1 commit into
Open
feat: the daemon serves an HTTP API, and each room asks for what it shows#64ceounittt wants to merge 1 commit into
ceounittt wants to merge 1 commit into
Conversation
…hows
kaeru-mcp has spoken HTTP since it grew a streamable transport; what it has
not had is an API. The MCP transports carry JSON-RPC, which only a client
that speaks MCP can use, so the visualizer was handed a side door —
`/graph.json` — that corresponds to no verb and grew its own config, its own
env vars and its own CORS rule. Every room then lived on that one answer,
and the answer was "everything".
The rule here is that **a route is a verb**. `/v1/board` is `board`,
`/v1/at` is `at`, `/v1/chain` is `read_chain`, `/v1/export` is `export`.
Nothing invents a vocabulary the curator API does not already have, so the
tools and the routes stay two transports over one implementation rather than
two implementations to keep in agreement. No `POST /call/{verb}` tunnel
either: an RPC tunnel cannot be cached, cannot be opened in a browser, and
reads as noise in a log.
Three things are fixed in the shape rather than left to each handler. All
three are free now and expensive to retrofit:
- `/v1/` on every path.
- a `Principal` argument on every handler, so a caller identity can grow a
variant instead of a signature. Today there is exactly one variant and it
carries nothing; that is the point.
- `egress` on the way out, so redaction and the operator's initiative
ceiling live in one auditable place instead of in each handler.
**What the verbs answer that the export could not.** The export selects a
node's name, tags, body and the rest — not `properties` — and an initiative's
column registry lives in the Board node's properties. So the board drew a
built-in *open / in progress / done* whatever an initiative had actually
configured — an initiative that had added a fourth column could not show it
to any client but the chat. The export also truncates every body, because a
document carrying them in full would be unusable; the only way to real text
was `?bodies=true`, which is every body in the vault.
Measured against a copy of a real vault:
reader, opening a three-step trail 3.7 MB -> 6.3 KB (one request)
board, opening one card 3.7 MB -> 1.4 KB
column registry, 19 initiatives 130 KB -> 3.6 KB (`columns=true`)
one node's body 241 chars -> 1229 chars
**Permission.** Node-addressed verbs have a problem the earlier ones did not:
the export judges the ceiling per node as it builds, and a board is named by
the initiative in the request, but a node id says nothing about who may read
it. So `reaches_node` asks the junction, and one reachable initiative is
enough — the same rule the export applies. A node attached to no initiative
is unreachable, deliberately: the ceiling is written in initiatives, and an
unfiled node has none to have been opted into. Verified by narrowing the
ceiling to one initiative: a node outside it answers 404 from `/v1/at`, its
board answers 404, and the export narrows to the 43 nodes that remain — three
verbs, one rule. 404 rather than 403 throughout, because saying "exists, but
not for you" is saying it exists.
A chain step outside the ceiling is dropped rather than redacted. Redaction
says "there is something here you may not read"; for a trail the honest
answer is that this line of work was never shared.
**Nothing here requires the API.** A baked snapshot has no daemon and an
older daemon has no routes, so every call degrades: `/v1/export` falls back
to `/graph.json` (still served as an alias), `/v1/board` to the built-in
vocabulary, `/v1/at` to the whole-bodies fetch. Which path to take is decided
once, by the only probe that can tell the difference — `/v1/at` with no id is
a *bad request* where the route exists and a *not found* where it does not.
Every other probe confuses "no such node" with "no such endpoint", because
both answer 404. Verified end to end against a daemon without the API: every
call 404s, the old paths answer, and the drawer shows the same text it did
before.
**Three interface faults surfaced while working on the rooms**, all in things
that had been wrong for a while:
- *The console cut off its own dropdowns.* `#panel` carries
`overflow-x: auto`, and CSS will not let one axis scroll while the other
stays visible — `overflow-y` computes to `auto` alongside it. A menu
opening upward sits outside the box doing the clipping. Measured on the
same element: laid out 300px tall either way, but one option of nineteen
was painted. It is `position: fixed` now, placed from script, and it stops
short of the window's edge as well as growing past the panel's.
- *The rail was the height of the window and swallowed drags.* Anchoring
both `top` and `bottom` made twenty-eight projects a wall down the right
side; it is as tall as its content now — 336px instead of 708 — capped at
twelve whole rows, and the cap is measured from where a row ends because
the first row's margin is not part of its height. Separately, a drag
starting on the rail's own background did not turn the galaxy: it swept a
text selection across the page, 337 characters from the gap under the
search box. The rail is chrome over the galaxy but hit-tested like a
surface. `pointer-events: none` with the controls taking their events
back, and `user-select: none` on the chrome — an instrument panel is not
prose.
- *Two scrollers still had the browser's default bar.* The treatment was
written per scroller, so anything added later missed it. One rule now, on
`*` rather than `:root` because `scrollbar-color` inherits and
`scrollbar-width` does not.
Steps left on this line of work: `set_status` and `link` — the writes, where
the auth token stops being optional — and an ETag on the export, which is
cheaper than any restructuring for the one read that genuinely wants the whole
graph.
ceounittt
force-pushed
the
feat/mcp-http-api
branch
from
August 24, 2026 10:38
ff29b4d to
bb8bd1c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The ask was to wire the visualizer up properly: make the MCP server a normal
HTTP router, with MCP handles. It already was an HTTP router — axum, two
transports, a bearer middleware. What it had was no API. So this is the
narrower, sharper reading of that ask: the same verb set over a second
transport, so something that is not an MCP client can reach it.
The rule
A route is a verb.
GET /v1/exportexport— the whole graph, for the galaxyGET /v1/board?initiative=&when=&columns=boardGET /v1/at?id=&when=atGET /v1/chain?id=read_chainNothing invents a vocabulary the curator API does not already have. No
POST /call/{verb}tunnel either — an RPC tunnel cannot be cached, cannot beopened in a browser, and reads as noise in a log.
Three things are fixed in the shape rather than left to each handler, all
free now and expensive to retrofit:
/v1/on every path, aPrincipalonevery handler (one variant today, carrying nothing — that is the point), and
egresson the way out so redaction and the operator's initiative ceilinglive in one auditable place.
What this fixes
/graph.jsonwas one answer to one question: everything. Two consequences.The board was showing columns that were not the initiative's. The export
selects a node's name, tags, body and the rest — not
properties— and thecolumn registry lives in the Board node's properties. So the browser drew a
built-in
open / in progress / donewhatever an initiative had configured.On my own vault an initiative had added a fourth column, and no client but the
chat could show it.
Every room paid the galaxy's price for text. The export truncates bodies,
so the only route to the real thing was
?bodies=true— every body in thevault.
Measured against a copy of a real vault:
Permission
Node-addressed verbs have a problem the others do not: the export judges the
ceiling per node as it builds and a board is named by the request, but a node
id says nothing about who may read it.
reaches_nodeasks the junction, andone reachable initiative is enough — the same rule the export applies. A node
attached to nothing is unreachable, deliberately.
Checked by narrowing the ceiling to a single initiative: a node outside it
answers 404 from
/v1/at, its board answers 404, the export narrows to the 43nodes that remain. Three verbs, one rule. 404 rather than 403 throughout —
"exists, but not for you" is still telling you it exists.
A chain step outside the ceiling is dropped rather than redacted. Redaction
says "there is something here you may not read"; for a trail the honest answer
is that this line of work was never shared.
Nothing here requires the API
A baked snapshot has no daemon; an older daemon has no routes. Every call
degrades:
/v1/export→/graph.json(still served as an alias),/v1/board→ the built-in vocabulary,
/v1/at→ the whole-bodies fetch.Which path to take is decided once, by the only probe that can tell the
difference:
/v1/atwith no id is a 400 where the route exists and a404 where it does not. Every other probe confuses "no such node" with "no
such endpoint", because both answer 404.
Verified end to end against a daemon without the API: every call 404s, the old
paths answer, and the drawer shows the same text it did before.
Three interface faults surfaced on the way
All of them older than this branch.
The console cut off its own dropdowns.
#panelcarriesoverflow-x: auto, and CSS will not let one axis scroll while the otherstays visible —
overflow-ycomputes toautoalongside it, so a menuopening upward sits outside the box doing the clipping. Measured on the same
element: laid out 300px tall either way, one option of nineteen painted. Now
position: fixed, placed from script, and it stops short of the window'sedge as well as growing past the panel's.
The rail was the height of the window, and swallowed drags. Anchoring
both
topandbottommade twenty-eight projects a wall down the rightside; it is as tall as its content now (336px instead of 708), capped at
twelve whole rows — measured from where a row ends, because the first
row's margin is not part of its height. Separately, a drag starting on the
rail's own background did not turn the galaxy: it swept a text selection
across the page, 337 characters from the gap under the search box.
pointer-events: nonewith the controls taking their events back, anduser-select: noneon the chrome.Two scrollers still had the browser's default bar. The treatment was
written per scroller, so anything added later missed it. One rule now, on
*rather than:root, becausescrollbar-colorinherits andscrollbar-widthdoes not.Testing
218 workspace tests green, 10 of them new in
api::egress(the ceiling, theglob suffix, deny only ever adding, redaction). The rest of the verification
is above and was done against a copy of a real vault with a daemon on a
scratch port — the live one was not touched.
clippyandrustfmtare not installed in my toolchain, so neither ran here.Not in this PR
set_statusandlink— the writes. That is where the auth token stops beingoptional:
auth.rsis off by default today because the daemon is on loopbackand everything is read-only. Also missing: an ETag on the export, which is
cheaper than any restructuring for the one read that genuinely wants the whole
graph.