Skip to content

How to Develop Panel Extensions

A panel extension adds a panel type that users put in a layout, next to plots, tables and logs. Users drop signals on it, resize it, save it in a layout, and share that layout.

The generated Example Panel in a layout

The Example Panel that --with panel generates, in a layout on live signals.

A panel is a web page that Zelos loads in a sandboxed frame inside the panel. Zelos owns the panel around it: the title bar, the Edit sheet, the signals bound to it, and the queries that read their data. Your page asks for data and draws it.

Panels need Zelos 26.0.10 or later and @zeloscloud/app-extension-sdk 0.4.0 or later.

Create a project

zelos extensions create probe --with agent,panel

--with panel adds one panel, example, in web/src/panels/. Add agent for a Python runtime that produces the data, and tab for a tab of its own. Leave out agent for a panel that shows data other sources produce. In the Zelos App, Create Extension in the Extensions view does the same: pick Panel and anything else the extension needs.

The command renders the project and installs it locally as local.probe. The panel ships already built into dist/, so it shows in Add Panel before your first build. When you start changing it, run just install. After each change, run just build and Reload. See Develop App Extensions for every option.

The generated project:

probe/
├── extension.toml                    # Declares [agent] and [[app.panels]]
├── main.py                           # The agent runtime (with --with agent)
├── probe/
└── web/
    ├── package.json
    ├── vite.config.ts                # Builds src/panels/<id>.html into dist/panels/
    ├── public/panels/example.options.json
    └── src/panels/
        ├── example.html
        └── example/
            ├── main.tsx              # Mounts the panel in ZelosBridgeProvider
            └── ExamplePanel.tsx

Declare the panel

Each panel is one [[app.panels]] entry in extension.toml:

[zelos]
version = ">=26.0.10"

[[app.panels]]
id = "example"
name = "Example Panel"
description = "Latest value of each bound signal"
entry = "dist/panels/example.html"
accepts = []
companions = []
binds = "any"
default_height = 2
live_only = false
options_schema = "dist/panels/example.options.json"
ai_guidance = "Shows the latest value of each bound signal, one row per signal."

[package]
paths = ["dist"]

The fields that decide where the panel appears:

  • accepts lists event-type prefixes. A drag of signals with a matching event type onto an empty layout opens your panel with them bound. The Sensor Kit extension accepts acme.sensor.reading., so a dragged table of sensor readings opens its Telemetry panel.
  • companions lists event-type prefixes that join your panel when the same drag holds an accepted signal. Dragged alone, they plot.
  • binds = "accepted" makes a drop onto an existing panel keep only the signals that match accepts or companions. The default, "any", keeps every dropped signal.
  • ai_guidance tells the AI when to pick the panel. The AI can create any installed panel.

For every field and rule, see [[app.panels]] in the Manifest Reference.

Write the panel

main.tsx mounts the panel in ZelosBridgeProvider, which connects to Zelos and applies its theme:

import { ZelosBridgeProvider } from "@zeloscloud/app-extension-sdk/react";
import ReactDOM from "react-dom/client";
import { ExamplePanel } from "./ExamplePanel";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <ZelosBridgeProvider>
    <ExamplePanel />
  </ZelosBridgeProvider>,
);

Five hooks from @zeloscloud/app-extension-sdk/react cover a panel:

Hook Returns
usePanel() The panel instance: panelId, instanceId, title, the bound signals, options, environment (connectedProducers and needsProducerDisambiguation), and visible. Each signal has its source, message, signal, path, color, unit, eventType, dictionary and valueTable.
useTimeState() The host's time: playback (LIVE or PAUSED), cursorS, viewRange, dataRange. null when no workspace is open. Only the components that call it render again when time moves.
usePanelData(params) The latest frame of one data subscription. Pass null for none.
usePanelOptions(defaults) The panel's saved options merged over defaults, and a setter that saves a patch.
usePanelActions() setCursor, setViewRange, setTitle, copyText, toast, showMenu and plotSignals.
import { usePanel, usePanelActions, usePanelData, usePanelOptions } from "@zeloscloud/app-extension-sdk/react";

/** Formats a latest value: numbers get `precision` decimals, anything else is shown as-is. */
function formatValue(value: string, precision: number): string {
  const trimmed = value.trim();
  const number = Number(trimmed);
  if (trimmed === "" || !Number.isFinite(number) || Number.isInteger(number)) return value;
  return number.toFixed(precision);
}

export function ExamplePanel() {
  const panel = usePanel();
  const [options] = usePanelOptions({ precision: 3 });
  const { copyText } = usePanelActions();
  const data = usePanelData(panel && panel.signals.length > 0 ? { id: "latest", shape: "latest" } : null);

  if (!panel) return <p>Connecting to Zelos…</p>;
  if (panel.signals.length === 0) return <p>Drop signals here, or bind them in the panel's Edit sheet.</p>;

  return (
    <table>
      <tbody>
        {(data?.latest ?? []).map((row) => (
          <tr key={`${row.message}.${row.signal}`} onClick={() => void copyText(row.value, "Value copied")}>
            <td>{row.signal}</td>
            <td>{formatValue(row.value, options.precision)}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Read data

usePanelData asks Zelos to run a query over the panel's bound signals and send each result. Zelos runs the query in the app, with the same limits as its own panels, and the page never talks to the agent. A rows subscription is sized to those limits for you: ask for the rows you want, and Zelos trims the request when many signals are bound. A panel's rows subscriptions share one budget of 1,000,000 cells (signals × rows), and the oldest subscription keeps its rows first. fitRowBudget gives the count for one subscription alone. If Zelos refuses a request because the page sent too many in one second, usePanelData sends the latest one again a second later. Any other refusal arrives as a frame with error set.

Parameter Meaning
id Your name for the subscription. The same id with new parameters replaces it.
shape "latest" sends the latest value of each signal. "rows" sends a window of rows in time order.
signals Path keys from panel.signals to query. Default: all bound signals. A subscription takes any number of them.
maxRows Rows only. A panel's rows subscriptions share 1,000,000 cells of signals × rows. Zelos cuts maxRows to the cells the panel's other subscriptions leave. Default: 125,000 rows.
sortOrder Rows only. "desc" (default) or "asc".
endAtCursor Rows only. When paused, end the window at the cursor instead of at the end of the view.
minPollMs The fastest the panel wants new data, in milliseconds. Default 200.

A panel holds up to 8 subscriptions. Zelos sends the next frame of a subscription after the SDK acknowledges the last one, or after one second without an acknowledgment. It sends nothing while the panel is scrolled out of view.

Act on the host

  • setCursor(timeS) moves the timeline cursor. setViewRange({ startS, endS }) zooms the view.
  • showMenu({ x, y, items }) opens a context menu that Zelos draws, so it can extend past the panel's edge. It resolves with the chosen item's id, or null.
  • toast({ tone, title, description? }) shows a toast that names your panel. Zelos shows at most one toast per second for a panel and drops the rest.
  • copyText(text, toast?) copies to the clipboard and confirms it with a toast. Every call copies. Only the toast shares the one-per-second limit.
  • plotSignals(paths) opens a Plot panel of those signals under this one.
  • setTitle(title) shows a title while the panel runs. It is not saved.

showMenu and copyText work only in answer to the user's input in the panel, such as a click, a right-click, or a choice in the panel's menu. Without that input, they fail with INVALID_STATE.

A panel can't make some calls that a tab can make. They fail with INVALID_STATE, and the message names the method:

  • tools.call for any notebook tool, the read ones too, and for one of Zelos's own tools whose effect is "edit" or "execute", such as annotation_create. A panel can call signal_at_time, signal_summarize and the other read tools. It can also call an extension's actions, the ext_* tools, as actions.execute can.
  • layouts.create, layouts.update and layouts.delete. A panel can call layouts.list and layouts.show.
  • The extensions.* calls that install, uninstall, reinstall, update, start or stop an extension.

Keys

The timeline's keys work after a click inside your panel. Space plays and pauses, the arrows step the cursor, and Home and End jump to the start and the end. The SDK passes up each key that your page did not use, and Zelos answers it as if it was pressed outside the panel.

Your page uses a key when you call preventDefault() on it, or stop it before it reaches the window. Call preventDefault() on a key your code answers, such as the arrows in a grid. Call stopPropagation() on a key whose browser action you want, such as the keys that scroll a list, because preventDefault() cancels that action. These keys stay in your page without either call:

  • A key typed into an input, a text area, a select or an editable element.
  • Enter on a button, a link, a summary, or another control that Enter activates.
  • Space on a button, a summary, a checkbox, a switch, or another control that Space activates. Space on a link is passed up, because Space does not activate a link.

This needs @zeloscloud/app-extension-sdk 0.4.1 or later. On 0.4.0, every key stays in the panel.

Options

options_schema points at a JSON Schema for the panel's settings. Zelos renders it as a form in the Config tab of the panel's Edit sheet, under the title and description, with the same widgets as an extension configuration form. A change saves in the layout at once and reaches the page through usePanelOptions.

{
  "type": "object",
  "properties": {
    "precision": { "type": "integer", "title": "Decimal places", "minimum": 0, "maximum": 9, "default": 3 }
  }
}

Your page can save options too: the setter from usePanelOptions merges a patch, and a null value removes a key. Zelos does not check saved options against the schema, so read them with defaults, as usePanelOptions does.

Develop in a browser

just dev

just dev starts the Vite dev server. The example panel is at /panels/example.html. Outside Zelos, ZelosBridgeProvider connects to the SDK's mock host. connectOptions mounts the page there as a panel with signals bound to it. The generated project already does this in web/src/panels/example/main.tsx, only in the dev server. Inside Zelos, the host sends the real panel instead. The file looks like this:

import type { AppBridgePanelSignal } from "@zeloscloud/app-extension-sdk";
import { ZelosBridgeProvider } from "@zeloscloud/app-extension-sdk/react";
import React from "react";
import ReactDOM from "react-dom/client";
import { ExamplePanel } from "./ExamplePanel";
import "../../index.css";

// `npm run dev` has no Zelos host: the SDK mounts this page as a mock panel bound to these signals.
const DEV_SIGNALS: AppBridgePanelSignal[] = [
  {
    source: "demo",
    message: "sensor",
    signal: "voltage",
    path: "demo/sensor.voltage",
    unit: "V",
    color: "#3b82f6",
  },
  {
    source: "demo",
    message: "sensor",
    signal: "current",
    path: "demo/sensor.current",
    unit: "A",
    color: "#f97316",
  },
];

const rootElement = document.getElementById("root");

if (!rootElement) {
  throw new Error("Missing #root element");
}

ReactDOM.createRoot(rootElement).render(
  <React.StrictMode>
    <ZelosBridgeProvider
      connectOptions={
        import.meta.env.DEV
          ? {
              panel: { panelId: "example", instanceId: "dev", signals: DEV_SIGNALS },
              time: { playback: "LIVE", cursorS: null, viewRange: null },
            }
          : undefined
      }
    >
      <ExamplePanel />
    </ZelosBridgeProvider>
  </React.StrictMode>,
);

A signal's path is <source>/<message>.<signal>, the key that the signals parameter of usePanelData takes.

The mock panel has no data, so the example panel shows an empty table until it receives frames, for example from pushData or startPush below.

The mock bridge adds setPanel(patch), setTime(patch), pushData(frame), and startPush(id, produce, everyMs), so you can play data into the panel and move the cursor. Panel actions check their parameters as Zelos does, then resolve with development defaults: setCursor moves the mock cursor, and setOptions saves into the mock panel. The mock host does not require the user's input for showMenu or copyText.

The generated Example Panel in the dev server

The Example Panel in a browser tab, on the mock host.

Develop inside Zelos

just build
zelos extensions install-local .

The panel appears in drop routing, in Add Panel, and in the AI tools within a few seconds. A web change needs only just build. After any change to extension.toml, such as a new panel, run zelos extensions install-local . again.

For fast iteration, rebuild on every change:

just watch

Then choose Reload from the panel's ⋮ menu. Only a local install shows Reload.

What Zelos does and does not do

Zelos does:

  • Load the panel in a sandboxed frame at its own origin, zelos-app://<extension id>, with no network access. This is the same sandbox as a tab. See Sandbox and network restrictions.
  • Bind signals to the panel from drops, the Edit sheet and the AI, and save them with the panel's title, description and options in the layout.
  • Run the queries and send the results, within the host's limits on rows, signals and poll rate.
  • Send time changes at most once per animation frame.
  • Draw the menus and toasts your page asks for.
  • Answer the keys your page did not use with its own shortcuts, such as the timeline's Space, arrows, Home and End.
  • Reload every panel of an extension within five seconds after it updates.
  • Show a placeholder that names the extension, with a button that opens its page, in place of a panel whose extension is not installed. The layout keeps the panel's signals and options.

Zelos does not:

  • Keep your page running when its layout tab is hidden. The frame unloads on a tab switch and loads again on return. Keep state that must survive in sessionStorage, keyed by usePanel().instanceId: every panel and the tab of an extension share one storage. Applying a layout gives each panel a new instanceId, so state keyed by it starts over.
  • Check saved options against your schema.
  • Draw an empty state for your panel. Your page shows its own.
  • Give the page access to the agent or the network. Every read goes through the bridge.
  • Let the page write layouts, call a Zelos tool that edits or runs code, or install, start or stop an extension. A tab can. See Act on the host.

Package and publish

Run just package. It builds the web project, then runs zelos extensions package ., which refuses a project whose panel files are missing from dist/. The generated release workflow runs just package too. See Package and Publish Extensions.

Reference