Files
Skald-Circle/dev-docs/plugins.md
T
Daniele 902f47ecd8
Nightly Build / build (push) Successful in 10s
docs: split CLAUDE.md into an always-loaded core plus dev-docs/
CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.

The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.

Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.

CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.

No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
2026-08-24 18:04:43 +01:00

4.5 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.