Against a self-hosted Honcho 3.0.11 every read path came back empty while the server was healthy and full of derived facts: the plugin parsed a `conclusions`/`summary` shape the API no longer emits. - honcho-client: typed models for the real schema — `PeerContext` (`representation` markdown + `peer_card`), `SessionContext` (`summary` as an object, `peer_representation`), wrapped `PeerCard` (a bare array on PUT is a 422, which also broke `honcho_profile` writes). - plugin: the turn-time injection and `honcho_context` read the representation; `honcho_search` and the page's /search now use `conclusions/query` (observer/observed scoping inside `filters`) — a real ranked semantic search with fact ids, which `peer_context?search_query` never provided; /overview returns card + representation + conclusions. - compose: pin the Honcho image by digest (3.0.11) — ghcr publishes no v3 semver tags, and an untracked `:latest` pull is what drifted the schema. - tests: fixture tests from payloads captured on the live server, plus an env-gated live smoke test (HONCHO_E2E_URL/_WS/_PEER, `cargo test -p honcho-client -- --ignored`) — run it before any future Honcho bump.
6.9 KiB
Skald dev-docs — architectural reference for coding agents. Index: README.md · Entry point: ../CLAUDE.md
Read this when: you touch the plugin system: visibility, per-user config, HTTP routers or plugin-contributed web pages.
Plugins
Plugin visibility & per-user config. The admin surface is #plugins (plugin-catalog.js), a status board — one card per plugin with an enable toggle + health dot + a Configure button — plus #plugin-detail?id=<id> (plugin-detail.js), which holds the instance-config form for one plugin (the plugin counterpart of connector-detail.js). Granting is user-side, exactly like a connector grant: the checkboxes live in the Plugins section of #users/{id} (users-page.js), right below that person's connectors, and the plugin's own page keeps only a read-only roster of who holds it, linking there. The question an admin asks is "what may this person use", and answering it plugin-by-plugin meant opening every plugin in turn; one write path also means the two surfaces cannot disagree. Unlike an MCP grant — which gates a runtime snapshotted at login and so needs a synchronous revoke — a plugin grant is re-read from plugin_access on every request that depends on it (sidebar pages, /plugins/mine, and each inbound channel message: Telegram checks it per message), so a revoke lands with no push and nothing on the bus. Binding-managed plugins (Plugin::manages_own_access, e.g. mobile-connector) are absent from the user-side list and rejected by its writer — a box that controls nothing is worse than no box. There is no generic per-user plugin page: a plugin with per-user settings (Telegram's pairing, Honcho's opt-in) hosts them in its own sidebar page via Plugin::web_pages(), like mobile-connector. Enable/disable + instance config + access grants are gated by the plugin.manage capability (admin-only by construction). Visibility is a row in plugin_access(plugin_id, user_id), which grants a user sight of an enabled plugin (plugin_id is bare TEXT, never a FK — a plugins row exists only after the first toggle); the table is deny-by-default but the rows are written for you at install time — see default-access.md. Per-user values are stored in plugin_user_configs (admin-readable system.db — never secrets) and applied through the Plugin::update_user_config hook, whose default just stores the blob via the PluginUserConfigApi on PluginContext.user_config. Telegram is the reference impl: its pairing page (a web_pages() fragment with no backend of its own) reads the {linked, chat_id} status blob from GET /api/plugins/mine and submits the code through PUT /api/plugins/{id}/my-config; the override turns it into a chat_id → user_id binding (same write path as the telegram_pairing tool). Endpoints: admin GET/PUT /api/plugins[/{id}], GET /api/plugins/{id}/access (read-only roster) + GET/PUT /api/users/{id}/plugins (the grant write path, the twin of /api/users/{id}/connectors); user GET /api/plugins/mine + PUT /api/plugins/{id}/my-config.
Plugin HTTP routes & web pages. Every plugin's http_router() mounts at boot under /api/plugin/<id>/ — enabled or not: two shared gates wrap each router (require_auth, then guard::plugin_enabled_gate, which re-checks the DB flag per request and answers 404 while disabled), so enable/disable serves/stops routes immediately with no restart, and plugin responses carry Cache-Control: no-cache. The router contract: cheap and safe to build pre-start, handlers tolerant of the not-running state (resolve runtime state per request through a shared cell, as mobile-connector does). A plugin may also contribute frontend pages via Plugin::web_pages() (PluginPage { page_id, title, icon, entry, admin_only, priority }): GET /api/plugins/pages returns the caller's visible pages (admin: all; others: non-admin_only pages of granted, enabled plugins) with entry_url resolved, and the sidebar renders them as menu entries routed #plugin/<plugin_id>/<page_id>. A single <plugin-page-host> (web/components/plugin-page-host.js) dynamic-imports the fragment ES module the plugin serves from its own router, registers its default-exported HTMLElement class, and mounts it with the plugin-id attribute — the fragment talks to its backend only through /api/plugin/<id>/… and runs with full session privileges (plugins are trusted: they ship in the binary). The frontend knows nothing about plugin page contents or behavior.
A plugin page with its own backend (Honcho is the reference). Most per-user plugin pages (Telegram's) need no backend of their own — the core /api/plugins/… endpoints carry their blob. Honcho's opt-in page grew a debug panel over the external memory server, and its router (crates/plugin-honcho/src/router.rs) is the pattern for that case, with three load-bearing rules. (1) The peer id is the multi-user boundary: the Honcho workspace is shared by every user, so every introspection handler receives its peer already resolved from the authenticated Caller — require_peer returns it or a 403 — and a client-supplied peer/workspace can never reach the external server. (2) The opt-in gate is server-side and fail-closed (require_peer re-reads the flag, same as the tools and the write path) — the panel hiding client-side when the flag is off is cosmetics, not the control. (3) Errors are specific, never "service unavailable": transport failures and the external server's HTTP status+body are localized and forwarded (truncated), because the page exists to debug the integration — and a 404 from the external server is translated per-endpoint as "no memory yet", which is a state, not a failure. The shared WebCell carries the live client + workspace + user-config store alongside the router's other deps, filled by start/stop.
Honcho's response schema is version-specific and bit us once. Verified against self-hosted 3.0.11 (the compose pins the image by digest — ghcr publishes no v3 semver tags, and a silent :latest bump is exactly how the drift arrived): peers/{id}/context returns {representation: markdown string, peer_card: string[]|null} — no conclusions array — and sessions/{id}/context returns summary as an object ({content,…}) plus peer_representation. The card endpoints wrap the list ({"peer_card": …}) in both directions — a bare array on PUT is a 422. Semantic search with ranked ids is POST conclusions/query with the observer/observed scoping inside a filters object — peer_context?search_query=… is not a substitute (it returns the whole representation once it fits the budget, with no ids). All of this is typed in honcho-client::models and pinned by fixture tests from real payloads, plus a live smoke test (HONCHO_E2E_* env vars, cargo test -p honcho-client -- --ignored). If a future Honcho upgrade changes shapes again, that test is the five-second check.