Status: Accepted (sliced; PR1 = thin vertical)
Plugins already extend the backend (tools, subagents, MCP servers, lifecycle
surfaces, FastAPI routers at /plugins/<id>) per ADR 0018,
and the Settings surface (config/secrets → fields under Settings →
Integrations) per ADR 0019. The
delegates panel + Discord/Google are the worked precedents.
What a plugin cannot do is add its own console surface — a left-rail icon
opening a full view (a dashboard, a board, whatever the fork wants), with its own
sub-tabs. The rail and surfaces are hardcoded in apps/web/src/app/App.tsx:
type Surface = "chat" | "activity" | … (a fixed union), hardcoded RailButtons,
surface === "x" ? <Component/> : null, and fixed sub-tab unions
(ActivityTab, etc.). The console is a compiled Vite SPA; plugins are runtime
Python — so there's no point to inject a React view.
But two of the three expensive seams already exist: a plugin can serve arbitrary
HTTP at /plugins/<id>/… (register_router), and the enabled-plugin list is
already shipped to the frontend via /api/runtime/status. This ADR adds the
third: a way for a plugin to declare a console view, and for the console to
render a rail icon + host it — without a console rebuild.
This is "ADR 0018 for the frontend."
A plugin declares views as data in its manifest; the console renders a dynamic rail icon per view and hosts the view as a same-origin iframe of a page the plugin serves. Locked decisions:
The plugin serves its own UI (any framework) at a route under /plugins/<id>/…;
the console renders a rail entry whose panel is <iframe src="/plugins/<id>/…">.
- Why not module federation (plugins ship React components into the bundle): heavy build tooling, version pinning, and it runs plugin JS in the console's own module graph — against the "drop in a Python package" ethos.
- Why not schema-only (plugin declares data, console renders generic widgets): great for native-look dashboards but caps expressiveness; kept as a complementary future option (D8), not the v1.
- Iframe-embed gives forks any dashboard they want with zero console rebuild, reusing the existing router seam.
# protoagent.plugin.yaml
views:
- id: board # unique within the plugin
label: "Board" # rail + tab label
icon: LayoutDashboard # a lucide icon name (see D4)
path: /plugins/myplugin/board # what the iframe loads (plugin-served)
placement: rail # "rail" (default — a left-rail surface) | "right"
tabs: # optional sub-nav (view-tabs)
- { id: open, label: "Open", path: /plugins/myplugin/board?tab=open }
- { id: done, label: "Done", path: /plugins/myplugin/board?tab=done }Placement (later addition). A view's optional placement puts it either in the
left rail as a full surface (rail, default) or in the right sidebar as a panel
alongside Notes / Beads / Goals / Schedule (right). Both render the same iframe host;
right is the substrate for moving Notes itself to a plugin. (No backend change — the
manifest passes view dicts through verbatim; the console filters by placement.)
Declared as pure data (like config/settings, ADR 0019) so it's known
without importing the plugin, and surfaced to the frontend via
/api/runtime/status (the plugin meta already flows there). The plugin serves
path via register_router — already supported.
App.tsx's hardcoded Surface union becomes a registry: the fixed core
surfaces plus plugin views read from runtime-status. A plugin surface is
keyed plugin:<id>:<viewId>; selecting it renders a generic PluginView
(<iframe>), and its tabs render the existing stage-subnav data-driven.
Core surfaces are unchanged in behavior.
icon is a lucide-react icon name the console maps from a curated allowlist
(broadened to cover dashboards, data, comms, dev, AI, finance, space/fleet, and
security — e.g. LayoutDashboard, BarChart3, Rocket, Satellite, Bot,
Coins, Workflow, Shield); an unknown/missing name → a default "plugin" glyph.
A plugin may instead point icon at an SVG it serves (/plugins/<id>/icon.svg)
for a custom mark. Lucide-name is the common path (matches the rest of the rail).
The console and /plugins/<id>/… are the same origin (one FastAPI app), so the
iframe inherits the operator-console posture (localhost-default + bearer-when-
exposed, #581/#591) and same-origin cookies/session. When a bearer is configured,
the console hands the iframe its token via postMessage after load (not a URL
query param — avoids token leakage to logs/history). The plugin page listens for
the handshake and uses the token for its own /api/plugins/<id>/… calls.
An enabled plugin already runs in-process as the agent (ADR 0018 trust model —
"don't enable code you don't trust"). Its view runs in an iframe with
sandbox="allow-scripts allow-forms allow-same-origin" (same-origin is required
for it to call its own API). This is isolation-of-convenience (DOM/CSS scoping),
not a security boundary against a malicious enabled plugin — same posture as
the backend. Documented as such.
On iframe load the console postMessages the brand tokens (the @protolabsai/design
--pl-* ground, dark-first) so an embedded view can match the console look. Opt-in
for the plugin page; core views are unaffected.
- PR1 (this ADR): thin vertical. Manifest
viewsparsing + surface it inruntime-status; thehelloplugin gains aviews:entry serving a demo page; the console renders one dynamic rail icon + iframe end-to-end. Proves the whole loop. (Hardcoded-to-registry refactor minimal: append plugin views after core surfaces.) - PR2 (shipped): rail registry + PluginView host. Data-driven plugin rail
(
plugin:<id>:<viewId>), the genericPluginViewiframe host (load/error states), stale-surface fallback (disabled/missing → chat). e2e (#620). - PR3 (shipped): view-tabs + auth/theming bridge + sandbox + docs. Declared
tabs→stage-subnav; the post-loadpostMessagetoken + theme handshake (protoagent:init); sandbox attrs; thehelloview is the reference receiver;docs/guides/building-react-plugin-views.md(canonical guide;chat_exampleis the copy-me reference). - Later (optional): schema-driven views (D1) for forks that want a native-look dashboard without serving their own page — a separate ADR if pursued.
- Forks get first-class console real estate — a rail icon + view (+ tabs) for
their plugin — by declaring data + serving a page, with no console rebuild or
fork of
App.tsx. Completes the extensibility story: backend (0018) + settings (0019) + surfaces (0026). - The console takes one structural change (rail: hardcoded → registry); after that, new plugin views are config, not code.
- Iframe isolation keeps a plugin's CSS/JS from colliding with the console, at the cost of a same-origin auth/theme handshake (D5/D7).
- Trust is unchanged: an enabled plugin's view is as privileged as its backend.
- Module federation / runtime React — rejected (D1): build/versioning/trust cost, breaks the Python-package ethos.
- Schema-driven-only — deferred (D1/D8): native look but limited; a good complement later, not the v1.
- Token via URL query — rejected (D5): leaks to logs/history; use post-load
postMessage.