Skip to content

Plugin dashboard widgets

A plugin widget (see ui-customization.md) lets a plugin contribute a live dashboard widget — not just the 8 built-in declarative widget types. This doc is the authoring guide: how a plugin declares a widget, what a widget bundle can assume about its execution environment, the bridge API it can call, the trust gate that controls whether it ever runs, and the security model underneath all of it.

This is a Wave E (chip E1 + E2) feature: E1 built the server-side manifest field, trust-gated serving route, and catalog read model (src/runtime/control-plane/plugin-widgets.ts); E2 (this doc) built the sandboxed iframe host and the bridge that lets a widget talk back to the dashboard.


Declaring a widget

Add a widgets array to your plugin's plugin.json:

jsonc
{
  "name": "my-plugin",
  "version": "1.0.0",
  "widgets": [
    { "id": "summary", "title": "My Plugin Summary", "entry": "dist/summary-widget.js" }
  ]
}
  • id — kebab-case, unique within this plugin's widgets array. Referenced from ui.json's plugin widget as widgetId.
  • title — shown as the widget's heading in the dashboard (unless the ui.json page overrides it with its own title).
  • entry — a path to a JS file, relative to the plugin's own directory (e.g. dist/summary-widget.js). Must stay inside the plugin directory — an absolute path or a ..-escaping path is a hard load error for the whole plugin (not just that one widget), because entry is a serving-time attack surface: the HTTP route reads straight off this resolved path. Fix the manifest; there is no partial-load fallback for this case.

A plugin can declare more than one widget; each is served and addressed independently.

Once installed, reference the widget from a dashboard page in ui.json:

jsonc
{ "id": "my-summary", "type": "plugin", "pluginId": "my-plugin", "widgetId": "summary" }

See ui-customization.md for the full ui.json schema and workflow — this doc only covers what's specific to the plugin widget type.


The trust gate

A widget's bundle is arbitrary plugin-authored code. It never runs (the dashboard won't even fetch it) until the owning plugin is trusted:

neo plugins enable my-plugin --trust

The dashboard shows an explanatory card — not a blank space — when a configured widget's plugin isn't trusted yet, including the exact command to run. Trust is re-checked on every request (not cached), so revoking trust (neo plugins disable, or the manifest changing since approval) takes effect immediately, before the next fetch.

If the plugin or widget id isn't found at all (typo, not installed, manifest doesn't declare that widget), the dashboard shows a distinct "not found" message instead of the trust message — these are deliberately different states so the owner knows whether to install/fix the plugin or just approve it.


The bundle contract

A widget bundle is a plain JavaScript file, no build step required. The dashboard fetches it (bearer-authenticated, same as every other /api/* call) and inlines it into a sandboxed iframe as an ES module:

html
<script type="module">
  /* your bundle's source, verbatim */
</script>

Before your bundle runs, the host defines one global: window.neo. Your bundle's job is to render into document.body using neo — there's no mount(el) function to export, no framework requirement, no build step. Top -level code runs immediately.

js
// dist/summary-widget.js
const el = document.createElement('div');
el.textContent = 'loading…';
document.body.appendChild(el);

neo.read('/api/overview')
  .then((data) => {
    el.textContent = `version ${data.version}, ${data.runningTasks} running`;
  })
  .catch((err) => {
    el.textContent = `error: ${err.message}`;
  });

neo.read(endpoint)

Promise<unknown> — GETs one of a small allowlisted set of read-model endpoints, the exact same list ui-customization.md publishes for declarative table/chart/stat-grid widgets:

/api/overview
/api/tasks
/api/sessions
/api/delegations
/api/channels
/api/memory
/api/daemon/status
/api/telemetry/summary
/api/telemetry/cost-by-model
/api/telemetry/tool-usage
/api/telemetry/errors
/api/commands

Any other endpoint rejects with {code: 'forbidden_endpoint'} — a plugin widget gets no more read access than a declarative widget already has.

neo.runCommand(name, args?)

Promise<unknown> — executes a command through the same command-RPC surface the dashboard's own command console uses, e.g.:

js
await neo.runCommand('tasks list');
await neo.runCommand('config set', { flags: { key: 'x', value: 'y' } });

args (optional) is { flags?, positionals? }, matching the command-RPC request shape.

Confirm-gated commands never auto-execute or auto-deny. If the underlying command requires confirmation (either the row/command catalog marks it confirm: true, or the server itself replies confirmation_required), the dashboard — not the iframe — shows the same confirm dialog every other destructive-command path in the dashboard uses. Your neo.runCommand() promise simply stays pending until the owner decides:

  • Confirmed → the promise resolves with the command's result, exactly as if it hadn't needed confirmation.
  • Denied / dismissed → the promise rejects with {code: 'confirmation_denied'}.

Your widget code doesn't need to do anything special to support this — just handle the promise rejecting, same as any other command failure.

neo.resize(height)

Fire-and-forget — tells the host iframe to resize to height pixels. The host also does this automatically via a ResizeObserver on your document's root element after load, so most widgets never need to call this directly; it's there for a widget that changes its own height after fetching data and wants the resize to happen immediately rather than waiting for the observer's next tick.

What's not in the contract (v1)

  • No dynamic capability negotiation — the three functions above are the entire API surface, always available, no version handshake.
  • No DOM access outside the sandboxed iframe's own document. See Security model — this isn't a bridge limitation, it's what the sandbox itself enforces.
  • No access to localStorage/sessionStorage/cookies of the dashboard page. Same reason.
  • No direct fetch() to the control-plane API. All data access goes through neo.read().

Security model

This is the part that matters if you're evaluating whether to trust a plugin widget, or writing one and wondering what you can/can't do.

  1. The bearer token never reaches your widget code. The dashboard fetches your bundle's source text using its own token, in its own JS context — never on your behalf, never handed to you. The srcdoc your bundle runs inside is built only from the fetched source text; there is no code path by which the token can end up in it.
  2. The iframe is sandboxed with allow-scripts only — no allow-same-origin. This gives your widget's iframe a permanently opaque origin. Concretely: your code cannot read the dashboard's localStorage/sessionStorage/cookies, and cannot make a same-origin fetch() to the control-plane API even if it wanted to (it has no Authorization header to do so with, and nothing here uses cookie auth). Every capability you have comes through window.neo, and nothing else.
  3. neo.read() is allowlisted, not a generic proxy — see the endpoint list above. You cannot read anything a declarative table/chart widget couldn't already read.
  4. neo.runCommand() goes through the real command-RPC path, including its confirm gate. A destructive command still requires the owner's explicit confirmation, shown in the dashboard's own trusted chrome — never inside your iframe (an iframe cannot forge that confirmation UI; the confirm dialog literally does not exist inside the sandboxed document).
  5. Every message between your widget and the dashboard carries a correlation id. Multiple in-flight neo.read()/neo.runCommand() calls resolve independently; this is plumbing detail, not something you need to manage yourself.
  6. The dashboard only ever listens to messages from its own iframe. It checks the message's source against the specific iframe it created — not the message's origin (which is meaningless here, since the sandboxed iframe's origin is opaque "null" for every plugin). A message from anywhere else is ignored.
  7. Trust, not code review, is the gate. Neo does not statically analyze widget bundles. neo plugins enable <id> --trust is a decision the owner makes about the plugin as a whole (same trust store every other plugin capability uses) — a widget bundle is exactly as trusted as the rest of that plugin's code.

Worked example: a two-widget plugin

plugin.json:

jsonc
{
  "name": "task-glance",
  "version": "1.0.0",
  "widgets": [
    { "id": "open-count", "title": "Open Tasks", "entry": "dist/open-count.js" },
    { "id": "quick-actions", "title": "Quick Actions", "entry": "dist/quick-actions.js" }
  ]
}

dist/open-count.js (read-only):

js
const el = document.createElement('div');
document.body.appendChild(el);

neo.read('/api/tasks').then((tasks) => {
  const open = tasks.filter((t) => t.status !== 'done').length;
  el.textContent = `${open} open task(s)`;
});

dist/quick-actions.js (a confirm-gated command, handled by the dashboard):

js
const button = document.createElement('button');
button.textContent = 'Clear finished tasks';
document.body.appendChild(button);

button.addEventListener('click', async () => {
  button.disabled = true;
  try {
    await neo.runCommand('tasks clear-done');
    button.textContent = 'Done.';
  } catch (err) {
    // If the owner denies the dashboard's confirm dialog, this rejects
    // with {code: 'confirmation_denied'} -- handle it like any other error.
    button.textContent = `Failed: ${err.message}`;
    button.disabled = false;
  }
});

ui.json page referencing both:

jsonc
{
  "id": "task-glance-page",
  "title": "Task Glance",
  "widgets": [
    { "id": "w1", "type": "plugin", "pluginId": "task-glance", "widgetId": "open-count" },
    { "id": "w2", "type": "plugin", "pluginId": "task-glance", "widgetId": "quick-actions" }
  ]
}

After neo plugins enable task-glance --trust, both widgets render live in the dashboard.


Troubleshooting

SymptomCause
"not trusted" cardRun neo plugins enable <pluginId> --trust.
"was not found" cardPlugin isn't installed, or widgetId doesn't match anything in its manifest — check GET /api/plugins/widgets or neo plugins list.
"failed to load plugin widget" cardThe bundle fetch itself failed (network, a 5xx, or the plugin/trust state changed between page load and fetch) — the card includes the server's error message.
Widget never appears to do anythingOpen the browser's devtools and inspect the iframe directly — bundle errors inside the sandboxed document don't propagate to the dashboard's own console.
neo.read() rejects with forbidden_endpointThe endpoint isn't on the allowlist above. Widgets cannot read arbitrary control-plane routes.