Plugin admin UI contribution model
Phase 99. How an out-of-tree plugin contributes config screens +
menus to the admin web app without forking it. Section reference:
Manifest [plugin.admin_ui].
Problem
Before Phase 99, any plugin-specific admin config (Google OAuth,
channel settings, allowlists) required editing YAML by hand or
forking nexo-rs-plugin-admin + rebuilding — breaking the Phase 90
axiom "admin out-of-tree, plugin-agnostic" and blocking the Phase 98
marketplace.
Design — hybrid descriptor
The descriptor is split between a cold contract and a live descriptor:
- Cold: the
[plugin.admin_ui]manifest section (validated TOML) declares the slots a plugin claims, its screens, and the render mode. This drives discovery + trust gating WITHOUT executing the plugin. - Live: the admin RPC
nexo/admin/plugin_ui/describereturns the current fields / values / options. The daemon synthesises this for static plugins (describe = false) from the manifest +cfg.plugins.<id>; it forwards to the plugin (over the[plugin.admin]broker channel) for dynamic ones (describe = true). The frontend consumes a uniformScreenDescriptoreither way.
admin SPA ──nexo/admin/plugin_ui/list────────▶ daemon (aggregate manifests,
◀──menu/submenu structure────────── slot+trust+visible_when gate)
──nexo/admin/plugin_ui/describe────▶ daemon ──(static) synth from cfg
◀──ScreenDescriptor───────────────── └─(dynamic) forward→plugin
──nexo/admin/plugin_ui/config_set──▶ daemon (validate merged vs
◀──ConfigSetResponse──────────────── config_schema → secrets to
cred store → write → reload)
Daemon side (nexo-core)
crates/plugin-manifest/src/admin_ui.rs— the section + validators (reusesconfig_schema.rs; menu/submenu viaContribution.parent).crates/plugin-manifest/src/visible_when.rs— the mini-DSL.crates/core/src/admin_ui/slot_registry.rs— slot vocabulary + trust matrix (TrustTierfrom Phase 98 provenance).crates/core/src/agent/admin_rpc/domains/plugin_ui.rs— thelist/describe/config_setdomain. Secrets route to the Phase 93 credential store;config_setvalidates the merged config (partial edits keeprequiredfields) + fires the Phase 97 reload signal.AgentEventKind::PluginUiChanged(firehose) — the admin re-fetches on install / uninstall / config change.
Frontend side (nexo-rs-plugin-admin)
Plugins surface under Settings › Plugins (/m/settings/plugins),
not as top-level rail entries. usePluginContributions fetches the
list + subscribes the firehose; GenericScreen renders the
descriptor (10 field renderers + actions + a visible_when port);
secret fields are write-only.
Modes
- Mode A (declarative) — shipped. Covers the vast majority: config forms, dynamic dropdowns, actions.
- Mode B (embedded iframe + ESM bundle) — deferred to v2 (see
FOLLOWUPS.md).mode = "embedded"is rejected by the v1 validator with a pointer to the follow-up.
Adoption requires a manifest-crate publish
PluginSection is #[serde(deny_unknown_fields)], so a plugin
pinning an older nexo-plugin-manifest from crates.io cannot even
parse a [plugin.admin_ui] section. Adopting the model is therefore
gated on publishing the manifest crate (≥ 0.1.8) and bumping each
plugin's dependency. The six canonical plugins (google, telegram,
email, whatsapp, browser, web-search) adopted it once 0.1.8 shipped.