Skip to content

Build a plugin view

A plugin adds a console surface by serving its own page and declaring it in the manifest; the console renders it in a sandboxed iframe. No host build. This is the short, copy-me entry — the full guide is Building a plugin view.

Copy the gold-standard

examples/plugins/chat_example is a single-page view that follows every rule below — start by copying it:

bash
cp -r examples/plugins/chat_example plugins/
yaml
plugins:
  enabled: [chat_example]

Reload. (chat_example claims the chat slot; a normal view adds a rail icon instead.)

The four rules

  1. Serve the path you declare — the manifest's views[].path MUST equal a path your register_router serves (default prefix /plugins/<id>; a custom prefix is fine, just keep them in sync). A mismatch is a blank iframe.
  2. Gate by default — mount data routes under prefix="/api/plugins/<id>" so they inherit the operator bearer gate. Use the ungated /plugins/<id> prefix only for genuinely public assets (the page itself — an iframe page-load can't carry an Authorization header).
  3. Same-origin, slug-aware, never hardcode — derive base = location.pathname.split("/plugins/")[0] and prefix every fetch/asset. Never hardcode /api/.../, /plugins/.../, or http://localhost:PORT — it breaks the /agents/<slug>/ fleet proxy and the same-origin token handshake.
  4. Link the DS kit<base>/_ds/plugin-kit.css + <base>/_ds/plugin-kit.js instead of hardcoding hex or a CDN. The kit's --pl-* tokens re-skin to the operator's live theme.

The kit helper API (plugin-kit.js)

The console serves the design-system kit same-origin at <base>/_ds/plugin-kit.{css,js}. plugin-kit.js is an ES module — a classic <script src> throws Unexpected token 'export' and never runs it, so load it with a dynamic import() (the slug-aware URL can't be a static import specifier):

HelperWhat it does
initPluginView(onInit?)Listens for the protoagent:init handshake (bearer + theme) and live protoagent:theme re-themes, mapping the console theme onto the DS --pl-* tokens. Call once on load.
getToken()The captured operator bearer (null until the handshake delivers one).
apiFetch(input, init?)Same-origin fetch with Authorization: Bearer <token> attached when present, resolved slug-aware — pass a bare /api/..., no manual base. For every gated /api/... call.
html
<script type="module">
  const base = location.pathname.split("/plugins/")[0];   // "" or "/agents/<slug>"
  const kit = await import(base + "/_ds/plugin-kit.js");
  kit.initPluginView();                                   // handshake + live re-theme, hands-free
  const res = await kit.apiFetch("/api/plugins/mychart/data"); // slug-aware, no manual base
</script>

Next

  • Building a plugin view — the full guide: the slot: "chat" panel (ADR 0045), the event-bus bridge (ADR 0039), the sandbox split (ADR 0026 D6), and references.
  • Plugins — the rest of the plugin contract (tools, subagents, config, MCP).

Part of the protoLabs autonomous development studio.