> ## Documentation Index
> Fetch the complete documentation index at: https://avala.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Panels

> Build sandboxed, versioned visualization panels with the TypeScript Panel SDK

Extend Avala's viewer with custom visualization panels using
`@avala-ai/panel-sdk`. A panel is a small TypeScript project: you write lifecycle
hooks, subscribe to MCAP topics, and draw. Panels run **sandboxed** — inside an
iframe with an opaque origin and a strict Content Security Policy — and talk to
the viewer over a **versioned, typed protocol**, so they stay stable across
viewer releases.

<Info>
  **Preview — not yet published to npm.** `@avala-ai/panel-sdk` (`0.1.x`) lives in
  the Avala monorepo and is **not on the npm registry yet**, so the `npx` flow
  below does not work today — build the CLI from source instead (see
  [Building the CLI from source](#building-the-cli-from-source)). Running a panel
  **inside Mission Control** depends on the panel host, which is in active
  development — until it ships, build and preview against the local dev harness
  (`avala-panel serve`). Distribution (`pack` / install) and the marketplace are
  on the roadmap; see [Distribution](#distribution) and
  [Marketplace](#marketplace).
</Info>

## Building the CLI from source

Until the package is published, build it from the monorepo once:

```bash theme={null}
cd sdks/panel-sdk
bun install
bun run build
# the CLI is now at sdks/panel-sdk/packages/sdk/dist/avala-panel.js
```

Then invoke it directly (substitute this for `npx avala-panel` / `avala-panel`
in the commands below):

```bash theme={null}
node <repo>/sdks/panel-sdk/packages/sdk/dist/avala-panel.js init my-custom-panel
```

`init` notices it is running from a source checkout and points the new panel's
`@avala-ai/panel-sdk` dependency at that checkout, so `npm install` and
`npm run build` work without the package being on the registry. After
publication, `init` emits the normal published version range instead — nothing
in your panel changes.

## Getting Started

Scaffold a new panel project:

```bash theme={null}
# Once published (see the note above), this will be:
npx @avala-ai/panel-sdk init my-custom-panel
cd my-custom-panel
npm install
npm run build
npm run serve
```

This creates a panel project with the following structure:

```
my-custom-panel/
  src/
    index.ts          # Panel entry point (calls definePanel)
  panel.yml           # Manifest: id, version, permissions, entry
  package.json        # Dependencies and build scripts
  tsconfig.json
  tsup.config.ts      # Bundles the panel (SDK inlined) into dist/index.js
```

`npm run build` compiles `src/` into a self-contained `dist/index.js`.
`npm run serve` starts the local **dev harness** at `http://localhost:5180`: it
loads your built panel in a sandboxed iframe and feeds it a synthetic recording
(a moving pose on `/robot/pose`, a draining battery on `/diagnostics/battery`),
so you can see your panel react to real message flow with no server, no login,
and no MCAP file. Run `npm run watch` in a second terminal to rebuild on save.

## Panel Lifecycle

You pass an object implementing lifecycle hooks to `definePanel`. The runtime
performs the handshake with the host, validates your settings, and calls your
hooks — you never touch `postMessage`.

| Hook                              | Called When                               | Use Case                           |
| --------------------------------- | ----------------------------------------- | ---------------------------------- |
| `onInit(ctx)`                     | Panel first renders                       | Set up DOM, canvas, initial state  |
| `onMessage(ctx, message)`         | A message arrives from a subscribed topic | Update the visualization           |
| `onSeek(ctx, timestamp)`          | The playhead moves or playback jumps      | Reset/replay for a new time range  |
| `onResize(ctx, width, height)`    | The panel container resizes               | Resize the canvas, reflow          |
| `onSettingsChange(ctx, settings)` | A setting changes                         | Apply new colors, filters, options |
| `onDestroy(ctx)`                  | Panel removed from the layout             | Clean up resources                 |

### Lifecycle Order

When a panel is first added to the layout:

1. `onInit` — set up your DOM elements and state
2. `onSettingsChange` — called immediately with the initial settings
3. `onSeek` — called with the current timeline position

During playback, `onMessage` is called for every incoming message in
chronological order. When the user scrubs the timeline, `onSeek` is called first,
followed by `onMessage` for messages in the new range.

## Data Access

Declare the topics you want in `topics`, and receive typed messages through
`onMessage`. Each `MessageEvent` carries `topic`, `logTime` (nanoseconds since
recording start), `schemaName`, and the decoded `data`.

### Basic Example

```typescript theme={null}
import { definePanel, type PanelContext, type MessageEvent } from "@avala-ai/panel-sdk";

definePanel({
  id: "custom-heatmap",
  name: "Heatmap Panel",
  description: "Visualize spatial density data as a heatmap",

  topics: [{ topic: "/sensor/occupancy", schemaName: "OccupancyGrid" }],

  settings: {
    colorMap: { type: "select", options: ["viridis", "plasma", "inferno"], default: "viridis" },
    opacity: { type: "number", min: 0, max: 1, default: 0.8 },
  },

  onInit(ctx: PanelContext) {
    const canvas = document.createElement("canvas");
    ctx.panelElement.appendChild(canvas);
    ctx.state.canvas = canvas;
    ctx.state.ctx = canvas.getContext("2d");
  },

  onMessage(ctx: PanelContext, message: MessageEvent) {
    renderHeatmap(ctx.state.ctx, message.data, ctx.settings.colorMap);
  },

  onSeek(ctx: PanelContext) {
    // `ctx.state` is a Record<string, unknown> — the SDK cannot know what you
    // put in it — so narrow on the way out. The scaffold enables `strict`, and
    // reading a property straight off `unknown` will not compile.
    const canvas = ctx.state.canvas as HTMLCanvasElement;
    const c2d = ctx.state.ctx as CanvasRenderingContext2D;
    c2d.clearRect(0, 0, canvas.width, canvas.height);
  },

  onResize(ctx: PanelContext, width: number, height: number) {
    const canvas = ctx.state.canvas as HTMLCanvasElement;
    canvas.width = width;
    canvas.height = height;
  },

  onDestroy(ctx: PanelContext) {
    (ctx.state.canvas as HTMLCanvasElement).remove();
  },
});
```

### Multiple Topics

List several topics and route on `message.topic`:

```typescript theme={null}
topics: [
  { topic: "/robot/pose", schemaName: "geometry_msgs/PoseStamped" },
  { topic: "/robot/goal", schemaName: "geometry_msgs/PoseStamped" },
  { topic: "/robot/path", schemaName: "nav_msgs/Path" },
],

onMessage(ctx, message) {
  switch (message.topic) {
    case "/robot/pose": updateRobotPosition(ctx, message.data); break;
    case "/robot/goal": updateGoalMarker(ctx, message.data); break;
    case "/robot/path": updatePlannedPath(ctx, message.data); break;
  }
},
```

### Dynamic Subscription

Add or drop subscriptions at runtime — for example, in response to a settings
change (this is exactly what the [Plot example](#example-plot-panel) does):

```typescript theme={null}
onSettingsChange(ctx, settings) {
  if (settings.topic) ctx.subscribe([{ topic: settings.topic as string }]);
}
```

## Context API

The `PanelContext` passed to every hook exposes the panel DOM, settings, state,
and viewer controls.

| Property              | Type                            | Description                                            |
| --------------------- | ------------------------------- | ------------------------------------------------------ |
| `panelElement`        | `HTMLElement`                   | Root DOM element for the panel.                        |
| `settings`            | `Record<string, unknown>`       | Current, schema-valid settings.                        |
| `state`               | `Record<string, unknown>`       | Mutable per-session state (canvas refs, caches).       |
| `currentTime`         | `number`                        | Current playback timestamp (ns since recording start). |
| `recording`           | `RecordingInfo`                 | Recording metadata: duration, topics, schemas.         |
| `theme`               | `ThemeTokens`                   | Viewer theme (`mode` + CSS token map).                 |
| `subscribe(topics)`   | `(TopicSubscription[]) => void` | Subscribe to topics at runtime.                        |
| `unsubscribe(topics)` | `(TopicSubscription[]) => void` | Drop subscriptions.                                    |
| `seekTo(timestamp)`   | `(number) => void`              | Move the playhead (needs `timeline:seek`).             |
| `log(level, ...args)` | `(level, ...unknown) => void`   | Forward a log line to the host.                        |

<Note>
  `state` is not persisted across sessions — use it for transient things like
  canvas references. For persistent configuration, use `settings`.
</Note>

## Panel Settings

Declare settings and the host renders a form; values are validated against your
schema before your hooks see them. An invalid value falls back to the previous
valid value (or the default) — your panel never receives an out-of-schema value.

| Type      | Renders                            | Example               |
| --------- | ---------------------------------- | --------------------- |
| `select`  | Dropdown of options                | Color map, mode       |
| `number`  | Numeric input (`min`/`max`/`step`) | Opacity, point size   |
| `boolean` | Toggle                             | Show/hide grid        |
| `text`    | Text input                         | Title, filter         |
| `color`   | Color picker (hex)                 | Overlay color         |
| `topic`   | Topic selector from the recording  | Dynamic topic binding |

```typescript theme={null}
settings: {
  colorMap: { type: "select", label: "Color Map", options: ["viridis", "plasma"], default: "viridis" },
  pointSize: { type: "number", label: "Point Size", min: 1, max: 20, step: 1, default: 4 },
  showGrid: { type: "boolean", label: "Show Grid", default: true },
  overlay: { type: "color", label: "Overlay Color", default: "#ff0000" },
  source: { type: "topic", label: "Data Source", schemaFilter: "sensor_msgs/*" },
}
```

## Permissions

Panels are **deny-by-default**. A panel declares the capability scopes it needs
in `panel.yml`; the host grants only those. Each scope carries a risk tag shown
to whoever installs the panel.

```yaml theme={null}
# panel.yml
apiVersion: panel.avala.ai/v1
id: custom-heatmap
name: Heatmap Panel
version: 0.1.0
entry: dist/index.js
publisher:
  name: Your Organization
compat:
  panelSdk: "^0.1.0"
permissions:
  - data:read
  - metadata:read
signature: null
```

| Scope               | Risk   | Grants                                 |
| ------------------- | ------ | -------------------------------------- |
| `data:read`         | low    | Messages from subscribed topics        |
| `metadata:read`     | low    | Enumerate recording topics and schemas |
| `timeline:seek`     | medium | Move the viewer playhead               |
| `annotations:read`  | medium | Read annotations                       |
| `annotations:write` | high   | Modify annotations                     |

Validate your manifest at any time:

```bash theme={null}
npx avala-panel validate
```

## Security Model

Panels run in an iframe with `sandbox="allow-scripts"` and **no**
`allow-same-origin`, so panel code executes at an opaque origin and cannot reach
the viewer's cookies, storage, or DOM. A Content Security Policy blocks external
hosts and network access (`connect-src 'none'`): your panel receives its data
through the protocol, not over the wire. Messages are authenticated by protocol
shape and frame identity, and the host↔panel protocol is semver'd — the viewer
refuses to run a panel whose major protocol version it does not understand.

The sandbox contains a panel from the viewer's own surfaces, but it is not a
complete data-exfiltration boundary (a panel can still leak data it was given via
navigation). The viewer therefore treats **permissions and panel review** as the
data boundary: panels are deny-by-default, are hosted on an isolated origin, and
should be granted data scopes only after review. Grant `annotations:write` and
other high-risk scopes deliberately.

## Building

```bash theme={null}
npm run build
```

This produces a self-contained `dist/index.js` — the module the sandboxed iframe
loads. Because the SDK is bundled in, there is nothing else to resolve at runtime.

## Distribution

<Info>
  Distribution and org-scoped install are **in development**. The `pack` command
  below already produces the install artifact; the Mission Control install flow
  and marketplace are being built next.
</Info>

Package your built panel for distribution:

```bash theme={null}
npx avala-panel pack
```

This writes `<id>-<version>.avala-panel.json` — a self-contained bundle carrying
the manifest and every module the build emitted: the entry plus any code-split
chunks (tsup splits ESM output by default when a panel uses dynamic
`import()`). It is the input to the (in-development)
GitHub-install flow and, later, the marketplace. Panels follow semantic
versioning, read from `panel.yml`.

## Marketplace

<Info>
  The marketplace is on the roadmap (ROADMAP 3.1). The `panel.yml` manifest
  already carries the fields a marketplace needs — `publisher`, a `signature`
  slot, and declared `permissions` — so panels built today are forward-compatible
  with it.
</Info>

## Example: Plot Panel

The `plot-panel` example (shipped in the SDK repo) plots a numeric value from any
topic over time. It demonstrates the topic-picker pattern and runtime
subscription:

```typescript theme={null}
import { definePanel, type PanelContext, type MessageEvent } from "@avala-ai/panel-sdk";

definePanel({
  id: "plot-panel",
  name: "Plot",
  topics: [], // the user picks a topic in settings

  settings: {
    topic: { type: "topic", label: "Topic", default: "" },
    maxPoints: { type: "number", label: "Window (samples)", min: 10, max: 20000, default: 600 },
    lineColor: { type: "color", label: "Line Color", default: "#4f9dff" },
  },

  onInit(ctx: PanelContext) {
    const canvas = document.createElement("canvas");
    ctx.panelElement.appendChild(canvas);
    ctx.state.canvas = canvas;
    ctx.state.g = canvas.getContext("2d");
    ctx.state.samples = [];
  },

  onSettingsChange(ctx: PanelContext, settings) {
    const topic = String(settings.topic ?? "");
    if (topic) ctx.subscribe([{ topic }]);
  },

  onMessage(ctx: PanelContext, message: MessageEvent) {
    // push a numeric value from message.data and redraw …
  },
});
```

See the full `plot-panel` and `trajectory-panel` sources in the SDK repository's
`examples/` directory.

## Next Steps

<CardGroup cols={3}>
  <Card title="" icon="table-columns" href="/docs/visualization/mcap-ros/panels">
    <p style={{fontWeight: 600, fontSize: '18px', marginBottom: '4px', marginTop: '8px', color: 'inherit'}}>Panel Types</p>
    <p style={{fontSize: '14px', marginTop: '0px', opacity: 0.6}}>Explore the built-in panel types available in the MCAP viewer.</p>
  </Card>

  <Card title="" icon="play" href="/docs/visualization/multi-sensor-viewer">
    <p style={{fontWeight: 600, fontSize: '18px', marginBottom: '4px', marginTop: '8px', color: 'inherit'}}>MCAP Viewer</p>
    <p style={{fontSize: '14px', marginTop: '0px', opacity: 0.6}}>Learn about the multi-sensor viewer that hosts custom panels.</p>
  </Card>

  <Card title="" icon="satellite-dish" href="/docs/visualization/fleet/fleet-dashboard">
    <p style={{fontWeight: 600, fontSize: '18px', marginBottom: '4px', marginTop: '8px', color: 'inherit'}}>Fleet Dashboard</p>
    <p style={{fontSize: '14px', marginTop: '0px', opacity: 0.6}}>Build fleet-level dashboards that incorporate custom panel visualizations.</p>
  </Card>
</CardGroup>
