Skip to content

Plugin console views (rail surfaces)

A plugin can add its own left-rail icon and view to the operator console — a dashboard, board, or whatever UI the fork wants — by declaring it in the manifest and serving a page. No console rebuild. This is the frontend counterpart to plugin tools/routes and plugin settings; see ADR 0026.

Declare a view

Add a views: block to protoagent.plugin.yaml:

yaml
views:
  - id: board                      # unique within the plugin
    label: "Board"                 # rail + tab label
    icon: LayoutDashboard          # a lucide-react icon name
    path: /plugins/myplugin/board  # the page the iframe loads (you serve it)
    placement: rail                # "rail" (default — left-rail surface) | "right" (right sidebar)
    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 chooses where the view lives: rail (default) is a full left-rail surface; right is a panel in the right sidebar alongside Notes / Beads / Goals / Schedule. Same iframe host either way.

The console reads this from /api/runtime/status and renders a rail icon per view (keyed plugin:<id>:<viewId>). When selected, it hosts path in a same-origin iframe that fills the stage; tabs render as a sub-nav that swaps the iframe page. icon is any lucide icon name — either PascalCase (LineChart) or kebab-case (line-chart). A curated common set (dashboards, data, comms, dev, AI, finance, space/fleet, security — e.g. LayoutDashboard, BarChart3, Database, Workflow, Bot, Rocket, Coins, Shield) renders instantly; anything else is lazy-loaded on demand, so you're not limited to an allowlist and the console bundle stays lean. An unknown name falls back to a generic plugin glyph.

Serve the page

The page is yours — any framework, or plain HTML. Serve it from the plugin's router (the same register_router that backs tools/routes):

python
def _build_router():
    from fastapi import APIRouter
    from fastapi.responses import HTMLResponse
    router = APIRouter()

    @router.get("/board")          # mounted at /plugins/myplugin/board
    async def _board():
        return HTMLResponse("<!doctype html>… your UI …")
    return router

def register(registry):
    registry.register_router(_build_router())

See the shipped plugins/hello for a worked example (a views: entry + a /view page).

The init handshake (bearer + theme)

After the iframe loads, the console posts a message to it — so your page gets the operator bearer (for its own API calls) and the console theme tokens (to match the look) without a token in the URL:

js
window.addEventListener("message", (e) => {
  const m = e.data || {};
  if (m.type !== "protoagent:init") return;
  // m.token — operator bearer (or null when none is configured); use it as
  //           `Authorization: Bearer <token>` for your /plugins/<id>/... calls.
  // m.theme — { bg, bgPanel, fg, fgMuted, brand, border } from the console.
  if (m.theme?.bg) document.body.style.background = m.theme.bg;
});

The message is sent same-origin and targeted at your page's origin.

Trust & sandbox

The view runs in an iframe with sandbox="allow-scripts allow-forms allow-same-origin". This scopes the plugin's CSS/JS from the console — it is not a security boundary against a malicious enabled plugin: an enabled plugin already runs in-process as the agent (same trust model as plugin backends). Only enable plugins you trust.

Lifecycle

  • Views appear for enabled plugins; disabling one (or a config reload that drops it) removes its rail icon and, if you were on it, falls back to Chat.
  • Mounting/serving is config-driven — adding a view to an existing plugin needs a restart (routes mount once at init), but the rail picks up the declaration from runtime-status with no console rebuild.

Part of the protoLabs autonomous development studio.