Skip to main content
More

Custom Inspector Tabs

Ship your own UI tabs alongside a Rivet Actor — embedded directly in the dashboard inspector.

Custom inspector tabs let you embed your own UI directly in the Rivet dashboard, next to the built-in tabs. Declare a tab on your actor, point it at a folder of static files, and the dashboard picks it up automatically.

Common uses:

  • Domain-specific debugging panels (queue depth, connection maps, log filters).
  • Operational tools (admin buttons, drain controls, snapshot uploaders).
  • Any author-defined view that ships with your actor.

A runnable example lives at examples/inspector-tabs.

Quickstart

Declare a tab on your actor:

import { actor, setup } from "rivetkit";

export const counter = actor({
  state: { value: 0 },
  actions: {
    increment: (c, amount: number) => {
      c.state.value += amount;
      return c.state.value;
    },
  },
  inspector: {
    tabs: [
      {
        id: "counter",
        label: "Counter",
        icon: "tag",
        source: "./inspector-tabs/counter",
      },
      { id: "queue", hidden: true },
    ],
  },
});

export const registry = setup({ use: { counter } });
registry.start();

Drop an index.html in the source directory:

<!-- ./inspector-tabs/counter/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <link rel="stylesheet" href="../../tab.css" />
    <style>
      /* The tab is mounted on the dashboard's `card` panel. */
      html,
      body {
        background: var(--rivet-surface, var(--rivet-card));
      }
    </style>
  </head>
  <body>
    <h1>Counter: <span id="value"></span></h1>
    <script>
      const SHELL_ORIGIN = (() => {
        const raw =
          new URLSearchParams(location.search).get("shellOrigin") ??
          location.origin;
        try {
          return new URL(raw).origin;
        } catch {
          return location.origin;
        }
      })();
      let token = null;

      window.addEventListener("message", async (e) => {
        if (e.origin !== SHELL_ORIGIN) return;
        if (e.data?.type !== "init" || e.data?.v !== 1) return;
        token = e.data.authToken;
        const r = await fetch("../../state", {
          headers: { Authorization: `Bearer ${token}` },
        });
        const { state } = await r.json();
        document.getElementById("value").textContent = state.value;
        applyTheme(e.data);
      });

      // Mirror the dashboard's theme. Re-run on every `init` — the
      // dashboard re-sends it when the user toggles the theme.
      function applyTheme(msg) {
        const root = document.documentElement;
        root.classList.toggle("dark", (msg.theme ?? "dark") === "dark");
        if (!msg.tokens) return;
        for (const [name, value] of Object.entries(msg.tokens)) {
          const varName = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
          root.style.setProperty(`--rivet-${varName}`, value);
        }
        const surface = msg.tokens[msg.surface ?? "card"];
        if (surface) root.style.setProperty("--rivet-surface", surface);
      }

      window.parent.postMessage({ type: "ready", v: 1 }, SHELL_ORIGIN);
    </script>
  </body>
</html>

Open the dashboard and the “Counter” tab appears alongside the built-ins.

Configuration

Each entry in inspector.tabs[] is either a custom tab or a hide modifier for a built-in.

Custom tab

{
  id: string,         // URL-safe id: /^[A-Za-z0-9_-]+$/
  label: string,      // Shown in the tab strip
  source: string,     // Directory of static assets
  icon?: string,      // Optional icon id
}
  • id — used as the URL segment and tab-strip key. Cannot collide with a built-in id (workflow, database, state, queue, connections, console).
  • source — directory of static files. The bytes you put there are the bytes the browser sees. Point it at a Vite/webpack dist/ and any framework works (React, Vue, Svelte, vanilla — all fine).
  • icon — one of workflow, database, state, queue, plug, terminal, tag, logs. Anything else falls back to a neutral icon.

Hide a built-in tab

{
  id: "workflow" | "database" | "state" | "queue" | "connections" | "console",
  hidden: true,
}

Use this to clean up the strip when the actor doesn’t use a given subsystem — e.g. a counter actor with no queues:

inspector: { tabs: [{ id: "queue", hidden: true }] }

Misconfigurations (missing directory, duplicate id, invalid characters, empty label) throw at registry construction, so problems show up immediately.

Talking to the dashboard

The tab loads in an iframe and communicates with the dashboard via postMessage. The contract is small.

From the dashboard

The dashboard sends an init message on load and again whenever the inspector token rotates. Always overwrite the cached token when it arrives.

{
  type: "init",
  v: 1,
  actorId: string,
  authToken: string,         // Per-actor inspector bearer token
  theme?: "light" | "dark",
  tokens?: Record<string, string>,  // Resolved CSS colors — see Styling
  surface?: string,          // Key in `tokens` for the host panel's color
  activeTab?: string,        // For multi-view tabs
}

Multi-view tabs can read the optional activeTab field on init to seed their initial sub-view. The dashboard does not send a separate message when the user switches custom tabs — it navigates the iframe src instead, so the tab reloads and receives a fresh init.

init is also the theme channel. The dashboard re-sends it whenever the user toggles the theme, so a tab that applies theme / tokens on every init follows theme changes without reloading. See Styling.

From the tab

Send ready once your message listener is registered:

{ type: "ready", v: 1 }

If a fetch returns 401, the token has rotated. Ask the dashboard for a fresh one and wait for the next init — don’t retry with the stale token:

{ type: "token-refresh-needed", v: 1 }

Security check

Always reject messages whose event.origin doesn’t match the ?shellOrigin= URL parameter. Without this check, any page that frames your tab could forge an init and feed you a fake token.

TypeScript types

If you build the tab with TypeScript, the message and response types are exported as types-only:

import type {
  V1Init,
  ShellToTabMessage,
  TabToShellMessage,
  InspectorStateResponse,
  InspectorActionResponse,
  InspectorRpcsResponse,
} from "rivetkit/inspector-tab";

Reading state and calling actions

The tab can hit any inspector endpoint with the supplied bearer token. Use relative paths so the tab doesn’t need to know the engine origin or actor id:

fetch("../../state",            { headers: { Authorization: `Bearer ${token}` } });
fetch("../../action/increment", { method: "POST", headers: { ... }, body: ... });
fetch("../../rpcs",             { headers: { Authorization: `Bearer ${token}` } });
fetch("../../connections",      { headers: { Authorization: `Bearer ${token}` } });
fetch("../../queue",            { headers: { Authorization: `Bearer ${token}` } });

The action body shape is { args: [...] } — the array is passed as positional arguments to the action.

Full endpoint reference: Debugging → Inspector Endpoints.

For high-frequency UIs, prefer the inspector WebSocket (/inspector/connect) over polling.

Styling

A shared stylesheet matching the dashboard’s design tokens is served at ../../tab.css:

<link rel="stylesheet" href="../../tab.css" />

It defines --rivet-* tokens for colors, spacing, radius, and typography, plus sensible defaults so a bare tab looks at home without custom CSS:

.my-card {
  background:    var(--rivet-card);
  color:         var(--rivet-foreground);
  border:        1px solid var(--rivet-border);
  border-radius: var(--rivet-radius-md);
  padding:       var(--rivet-space-4);
}

Which surface the tab is on

The tab iframe is mounted on the dashboard’s card panel, not on the page background. Use --rivet-surface (an alias of --rivet-card) for the tab’s own backdrop so it composites seamlessly with the panel around it. The stylesheet already paints html and body with it, so a tab that doesn’t override the body background needs no extra work.

Do not paint the tab with --rivet-background. That is the color of the dashboard shell around the panel, and using it leaves a visible seam.

If your tab sets its own body background, state the surface explicitly. The fallback keeps it correct against an older stylesheet that predates --rivet-surface:

html, body { background: var(--rivet-surface, var(--rivet-card)); }

Token names

Every dashboard token is exposed under a --rivet- prefix, and the same names arrive as init.tokens keys in camelCase (--rivet-muted-foregroundtokens.mutedForeground). These names are stable:

background, foreground, card, cardForeground, popover, popoverForeground, primary, primaryForeground, secondary, secondaryForeground, muted, mutedForeground, accent, accentForeground, destructive, destructiveForeground, border, input, ring.

Plus surface / --rivet-surface for the mounted surface described above. New tokens may be added; existing names won’t change meaning. init.tokens may carry keys your version of the stylesheet doesn’t know, so iterate it rather than reading a fixed list.

Color tokens also come in raw HSL form (--rivet-card-raw) so you can compose with alpha:

.overlay { background: hsl(var(--rivet-background-raw) / 0.6); }

Reacting to theme changes

The dashboard signals the theme two ways on init, and re-sends init every time the user toggles the theme — so a tab that applies both on every init updates live, with no reload:

  • theme ("light" | "dark") — toggle the dark class on <html>. The stylesheet’s token blocks key off it. Default to "dark" if absent.
  • tokens and surface — the dashboard’s resolved colors, as CSS color values ready to use in a declaration. Pinning them as inline --rivet-* custom properties makes the tab render the dashboard’s exact colors even if the stylesheet it vendored is older than the dashboard.
function applyTheme(msg) {
  const root = document.documentElement;
  root.classList.toggle("dark", (msg.theme ?? "dark") === "dark");
  if (!msg.tokens) return;
  for (const [name, value] of Object.entries(msg.tokens)) {
    const varName = name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
    root.style.setProperty(`--rivet-${varName}`, value);
  }
  const surface = msg.tokens[msg.surface ?? "card"];
  if (surface) root.style.setProperty("--rivet-surface", surface);
}

Tabs built with a bundler can import that logic instead of copying it:

import { applyInspectorTabTheme } from "rivetkit/inspector-tab";

applyInspectorTabTheme(init, document.documentElement);

tokens and surface are both optional. An older dashboard sends only theme, and the stylesheet resolves the right colors on its own — never hardcode hex values as a fallback, or the tab drifts the next time the dashboard’s tokens change.

You’re free to skip the stylesheet entirely and bring your own. If you do, init.tokens is the whole palette, and init.tokens[init.surface] is the color your body must be for the tab to sit flush in its panel.

Security

The tab runs in an iframe at the engine origin and can call any inspector endpoint with the supplied token. Treat the bundle like any author code that ships with your actor:

  • Don’t inline secrets. The bundle is fetchable by anyone who can reach the actor.
  • Always validate event.origin. Reject inbound messages from anywhere other than the dashboard origin.
  • Never retry silently on 401. Post token-refresh-needed and wait for a fresh init.

See also