Files
Skald-Circle/dev-docs/plugins.md
T
Daniele 9feaaaff29
Nightly Build / build (push) Successful in 2m24s
feat(plugin-honcho): show what Honcho remembers about you
The opt-in page gains a debug panel, once the user's saved flag is on:
service status (reachability, latency, the caller's own processing
queue, with the specific Honcho error when something is wrong), a full
overview (peer card, derived facts with ids, summary) and one text
field with two actions — search (raw ranked facts) and ask (Honcho's
server-side LLM answers), plus an in-page mini-guide.

Every endpoint gates on the per-user opt-in server-side, fail closed,
and derives the peer from the authenticated Caller — never from the
request body — since the workspace is shared. Honcho 404s are
translated per-endpoint as 'no memory yet' rather than failures.
2026-08-24 21:47:36 +01:00

5.8 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 Callerrequire_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.