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/webpackdist/and any framework works (React, Vue, Svelte, vanilla — all fine).icon— one ofworkflow,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-foreground
↔ tokens.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 thedarkclass on<html>. The stylesheet’s token blocks key off it. Default to"dark"if absent.tokensandsurface— 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-neededand wait for a freshinit.
See also
- Debugging — full inspector HTTP API
- Actions — actions your tab can invoke
- State — state your tab can read
examples/inspector-tabs— runnable example