Skip to main content
Sign In
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" />
  </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;
        document.documentElement.classList.toggle(
          "dark",
          (e.data.theme ?? "dark") === "dark",
        );
      });

      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",
  activeTab?: string,        // For multi-view tabs
}

For tabs with sub-views, the dashboard also sends set-active-tab when the user switches:

{ type: "set-active-tab", v: 1, tab: string }

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);
}

Toggle dark mode by adding the dark class to <html> — the dashboard sends the active theme in the init message.

Color tokens come in both pre-wrapped (--rivet-card) and raw HSL (--rivet-card-raw) forms, so you can compose with alpha:

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

You’re free to skip the stylesheet entirely and bring your own.

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