Skip to content

How to Develop App Extensions

A tab is a sandboxed web page that opens in its own tab in the desktop app. This page covers the tab, and the bridge SDK that tabs and panels share. Build a tab when you need to:

  • build custom UI inside Zelos
  • visualize or control extension-specific workflows
  • read typed bridge state such as theme and workspace metadata
  • call host-managed actions, extension lifecycle helpers, or trace writers from the iframe

Create a project

zelos extensions create my-dashboard --with tab

--with is required. It names the parts, as a comma list:

Value Adds
agent A Python agent runtime: main.py, a Python package and pyproject.toml.
tab A React and Vite web project under web/, with one tab in web/src/tab/.
panel One layout panel, example, in web/src/panels/. Without tab, it brings the same web project minus the tab.

Combine them in one project. --with agent,panel makes a Python runtime and a web project, and one extension.toml declares both.

The name must be kebab-case: it starts with a lowercase letter, and single hyphens join words of lowercase letters and digits. After it renders the project, the command:

  1. Runs uv sync when the project has an agent runtime.
  2. Runs git init and makes an initial commit.
  3. Installs the project into the local agent with install-local. It skips this step when --host points at another machine.

A failed step prints a warning and leaves the project in place.

Options:

zelos extensions create my-dashboard --with tab,panel \
  --author "Jane Doe" \
  --description "Custom dashboard for device health" \
  --output ~/src \
  --template-ref v0.2.0
Option Effect
--with Required. The parts: agent, tab, panel, comma-separated.
--author, --email, --github, --description Project metadata in extension.toml, web/package.json, and the README.
-o, --output Parent directory for the project. Default: the current directory.
--template-ref Template tag or branch. Default: the latest stable tag of zelos-extension-templates. Set GITHUB_TOKEN or pass this option if the GitHub API rate-limits tag discovery.
--no-setup Only render the files. Skip the steps above.

web/src/tab/main.tsx and each panel's main.tsx wrap the page in ZelosBridgeProvider from @zeloscloud/app-extension-sdk/react. The project ships the tab and the panels already built into dist/, where the manifest points, so it installs and opens before your first build. When you start changing it, run just install. After each change, run just build, then reopen the tab or choose Reload from the panel's ⋮ menu. Packaging and install-local refuse a project whose tab or panel files are missing from dist/.

In the Zelos App, Create Extension in the Extensions view does the same. Pick Tab, and Agent or Panel with it if you want them. The app installs the project right away but skips git init, so run it yourself. If the install fails, the dialog shows why, and the project stays in place.

For panels, see Develop Panel Extensions.

SDK versions

New projects start on @zeloscloud/app-extension-sdk ^0.4.0 and Node 24, the versions the template pins. On a 0.x package a caret range stays on its minor version, so a project made from an older template stays on an older SDK. Update the dependency to get the current one:

cd web && npm install @zeloscloud/app-extension-sdk@latest
SDK version Adds
0.1.x Snapshot and events, actions.list / execute, extensions.list / start / stop, trace.*, MockBridge, React provider and hooks
0.2.0 agents.*, query.*, tools.*, layouts.*, actions.schema(), the rest of extensions.*, error codes, bridge.hostVersion
0.3.0 ToolDescriptor.effect (read, edit, execute) in place of readOnly, and the NO_ORGANIZATION error code
0.4.0 Layout panels: panel.*, usePanel, useTimeState, usePanelData, usePanelOptions, usePanelActions, bridge.hostCapabilities, and the mock panel helpers. Needs Zelos 26.0.10 or later for panels; a tab built with it still runs on older releases
0.4.1 A panel passes the keys it does not use up to Zelos, so the timeline keys work after a click inside the panel

This page documents 0.4.1.

Develop standalone

cd my-dashboard
just dev

just dev starts the Vite dev server. The tab is at /tab/. In a normal browser tab, the page connects to the SDK MockBridge, so you can iterate without the desktop app.

  • snapshot and event APIs work immediately
  • invoke-based helpers such as actions.execute() or trace.init() need a stubbed MockBridge.setInvokeHandler(...) outside Zelos

Develop inside Zelos

just build
zelos extensions install-local .

create already installed the project unless you passed --no-setup. A local install serves the project folder directly, so 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

To load a new build, close and reopen the tab. Tabs have no hot reload.

A tab or panel has no process. zelos extensions start, stop and a plain reinstall (without --with) act on the agent runtime, so they work only on an extension with an [agent]. The SDK extensions.start() and extensions.stop() helpers follow the same rule. Neither reloads your tab.

Sandbox and network restrictions

Tabs and panels run under a strict Content Security Policy:

  • No external network - fetch(), XMLHttpRequest, and form submissions to http:/https: URLs are blocked by CSP
  • Origin isolation - each extension runs under zelos-app://extensionId, isolated from the host and other extensions
  • Iframe sandbox - allow-scripts allow-same-origin allow-downloads; no form submissions or popups. A tab loaded from an external https:// URL runs unsandboxed, in its own renderer process

When developing standalone with MockBridge, standard browser CSP applies (typically unrestricted for localhost). External API access is only available in standalone mode.

Connect to the bridge

Use either connectBridgeTransport() directly or the React provider entrypoint.

import { connectBridgeTransport } from "@zeloscloud/app-extension-sdk";

const bridge = await connectBridgeTransport();
const snapshot = bridge.getSnapshot();

console.log(snapshot.info.name);
console.log(snapshot.theme.preference);
console.log(snapshot.workspace?.modeKind ?? "no workspace");

const offWorkspace = bridge.on("workspace.changed", (workspace) => {
  console.log("workspace changed:", workspace?.name ?? "none");
});

window.addEventListener("beforeunload", () => {
  offWorkspace();
  bridge.destroy();
});

If you are using React, @zeloscloud/app-extension-sdk/react exposes ZelosBridgeProvider plus hooks like useExtensionInfo(), useTheme(), and useWorkspace(). The provider also applies the host's light/dark classes and design tokens to the document automatically.

Bridge surfaces and constraints

The host bridge starts with a snapshot in handshake-ack.snapshot, then pushes theme.changed and workspace.changed events as the host state changes. On top of that event stream, the public SDK exposes typed invoke helpers.

Surface What it does Important constraints
Snapshot + events Read info, theme, and workspace, then subscribe to theme.changed / workspace.changed workspace can be null when no workspace is open
agents.list() / agents.health() Discover connected agent addresses and the local agent's health read-only, available in any workspace mode; list() keys are the agent / addresses inputs for actions.* and query.*
actions.list() / actions.schema() / actions.execute() Discover, describe, and run agent actions omitting agent in list() fans out across connected agents; schema() is read-only and runs in any mode; execute() requires LIVE workspace mode, a connected target agent, and an explicit agent
extensions.* Inspect and manage installed extensions on an agent: list, info, start, stop, install, uninstall, reinstall, update, checkForUpdates, plus the metadata reads configSchema, lastConfig, readme, icon, getAsset start() / stop() use the host's saved config; restart() and installLocal() are deliberately not exposed. Omitting agent makes list() fan out across every connected agent; the other calls default to localhost. A panel can't install, remove, start or stop an extension
query.* Read-only live/trace signal discovery and timeseries reads (13 methods) live-keyed methods require LIVE mode and a connected agent (trace-keyed run in any mode); bulk reads are host-clamped: width 10k, signals 512, path keys 64, cells 1M (signals × width, or signals × maxRows, where a larger or missing maxRows gets the rows that fit); latest and at-time reads take any number of signals
tools.list() / tools.call() Discover and run the host's generic tool registry (agent actions plus the signal read tools) call() returns a result envelope, so a tool failure comes back as { ok: false, error } rather than rejecting; effect: "read" tools run in any mode, edit and execute tools require LIVE. A panel calls read tools and extension actions (the ext_* tools), not Zelos's notebook tools or its own edit and execute tools
layouts.* Console-layout CRUD synced through the agent requires the user to be signed in; update is a full replace of name / data / isPersonal; create defaults to personal. A panel reads layouts and can't create, update or delete one
trace.init() Open an isolated .trz writing session kind: "trz" is the only writer kind today; writer() chooses the destination file on the current desktop host

Run actions

Use actions.list() to discover what is available, then actions.execute() to target a specific agent:

import { actions, connectBridgeTransport } from "@zeloscloud/app-extension-sdk";

const bridge = await connectBridgeTransport();
const actionsByAgent = await actions.list(bridge);

console.log(actionsByAgent);

const result = await actions.execute<{ rate_hz: number }>(bridge, {
  agent: "localhost:2300",
  action: "sensor/set_rate",
  params: {
    sensor: "probe-1",
    rate_hz: 50,
  },
  timeoutMs: 5000,
});

console.log(result.status, result.result.rate_hz);

Notes:

  • actions.list() returns a record keyed by agent address
  • actions.execute() does not assume localhost; pass the target agent explicitly
  • when params is omitted, the SDK sends {} rather than undefined
  • timeoutMs is the host-side timeout; omit it to use the action's declared timeout, or pass 0 for none
  • actions.schema(bridge, { agent, action }) returns the action's input schema and UI schema as JSON strings, plus defaultTimeoutMs; parse them with JSON.parse

Read signals

Discover a connected agent with agents.list(), discover its signals with query.liveSignalsMulti(), turn each Signal into a path key with signalPath(), then read values:

import { agents, connectBridgeTransport, query, signalPath } from "@zeloscloud/app-extension-sdk";

const bridge = await connectBridgeTransport();

// 1. Discover a connected agent instead of hard-coding its address.
const statuses = await agents.list(bridge);
const address = Object.entries(statuses).find(([, connected]) => connected)?.[0];
if (!address) throw new Error("no connected agent");

// 2. Discover the agent's signals, then build `${source}/${message}.${signal}` path keys.
const signals = await query.liveSignalsMulti(bridge, { addresses: [address], duration: 30 });
const paths = signals.map(signalPath);

// 3. Read the latest value for each over the last 30 seconds.
const end = new Date();
const start = new Date(end.getTime() - 30_000);
const latest = await query.liveQueryLatestMulti(bridge, {
  agentSignals: { [address]: paths },
  start: start.toISOString(),
  end: end.toISOString(),
});

console.log(latest);

Notes:

  • live-keyed query.* methods require LIVE workspace mode and a connected agent; outside LIVE they reject with LIVE_MODE_REQUIRED (trace-keyed methods run in any mode)
  • rejected invokes carry a machine-readable .code from the APP_BRIDGE_ERROR_CODES registry; switch on it instead of matching message text:
    • UNKNOWN_METHOD: the host has no handler for the method
    • INVALID_PARAMS: params failed host-side validation or a cap
    • LIVE_MODE_REQUIRED: the call needs LIVE workspace mode
    • AGENT_NOT_CONNECTED: the target agent is not in the connected set
    • USER_CANCELLED: the user dismissed a host dialog, such as a save prompt
    • NO_HANDLER: the host registered no invoke handler at all
    • NO_ORGANIZATION: no signed-in user or organization for the host to act as
    • PANEL_ONLY: a panel.* method was called from a tab
    • UNKNOWN_SUBSCRIPTION: panel.unsubscribe or panel.ack named an id the panel does not hold
    • SUBSCRIPTION_LIMIT: a new subscription when the panel already holds 8, or its other subscriptions hold its whole cell budget
    • INVALID_STATE: the host cannot do this now or for this document, and the message says why
    • RATE_LIMITED: the frame passed a call limit, and the message names it
  • each frame, a tab or a panel, can have 32 calls in flight and start 200 calls in any one second; past either limit, a call rejects with RATE_LIMITED. A panel's panel.ack passes both limits
  • a document that handshakes more than 20 times in one minute, as a reload loop does, is refused with More than 20 handshakes per minute; the extension is reloading in a loop, and Zelos shows that error in its place
  • bulk timeseries reads are host-clamped: you can request fewer rows/signals/width, never more

Manage installed extensions

The bridge can inspect installed extensions on a selected agent and control the agent-hosted ones:

import { connectBridgeTransport, extensions } from "@zeloscloud/app-extension-sdk";

const bridge = await connectBridgeTransport();
const installedByAgent = await extensions.list(bridge, { agent: "localhost:2300" });

console.log(installedByAgent["localhost:2300"]);

await extensions.start(bridge, {
  agent: "localhost:2300",
  id: "acme.sensor-kit",
});

Important constraints:

  • this surface is for installed extensions on an agent, not for reloading your own tab
  • extensions.start() uses the host's last-saved config; there is no v1 API for supplying a fresh config payload
  • start(), stop() and reinstall() need an extension with an [agent], like the CLI commands
  • installLocal() is not exposed on this surface

Write .trz output

Use trace.init() to open an isolated namespace, then create a writer and stream frames into it:

import { trace } from "@zeloscloud/app-extension-sdk";

const ns = await trace.init({ name: "can-converter" });

try {
  const writer = await ns.writer({
    kind: "trz",
    suggestedName: "drive.trz",
    producer: "can-converter",
  });

  await writer.ingest(frame);
  await writer.ingest(nextFrame);

  const { saved } = await writer.finalize();
  console.log("saved:", saved);
} finally {
  await ns.drain();
}

Important constraints:

  • ns.writer(...) opens the native save dialog on the current desktop host and rejects if the user cancels before a writer is created
  • finalize() flushes and closes the writer
  • abort() discards the partial file without prompting

Standalone mocks and tests

MockBridge is useful when you want to run the extension outside Zelos or write focused UI tests:

import { MockBridge } from "@zeloscloud/app-extension-sdk";

const bridge = MockBridge.connect(
  {
    currentWindow: window,
    parentWindow: window,
    locationHref: window.location.href,
    matchMedia: window.matchMedia.bind(window),
  },
  {
    extensionId: "local.dev-extension",
    version: "0.1.0",
    name: "Development Extension",
  },
);

bridge.setInvokeHandler(async (method, params) => {
  switch (method) {
    case "actions.list":
      return { "localhost:2300": ["demo/ping"] };
    case "actions.execute":
      return { status: "done", result: { ok: true, params } };
    default:
      throw new Error(`Unexpected method: ${method}`);
  }
});

Without setInvokeHandler(...), invoke-based helpers reject with a clear "no invoke handler" error in standalone mode.

Troubleshooting

"App extension handshake timed out"

  • the embedded page loaded but never completed the bridge handshake
  • verify the [app] tab entry points at a built HTML file
  • rebuild before install-local and make sure the app still mounts ZelosBridgeProvider or calls connectBridgeTransport()

"Unsupported app bridge protocol version"

  • the iframe and host disagreed on protocolVersion
  • upgrade @zeloscloud/app-extension-sdk or regenerate from a current template, then run just build and reload

bridge.actions.execute says LIVE mode is required

  • open a LIVE workspace before calling the action
  • ensure the target agent is connected and that you passed its address explicitly

"Extension has no agent runtime; cannot start"

  • zelos extensions start, stop and a plain reinstall (without --with) need an extension with an [agent]
  • a tab or panel has no process: run just build, then reopen the tab or reload the panel

Package and release

just package

just package builds the web project, then runs zelos extensions package .; build first when you call the CLI directly.

The generated extension.toml includes the [package] section that packaging requires:

[package]
paths = ["dist"]

Use zelos extensions package --dry-run . to preview the files before packaging.

To release, run just release <version> and push with git push --follow-tags. The generated workflow builds the archive and creates the GitHub release. See Package and Publish Extensions for archive rules and marketplace submission.

Manifest reference

A complete manifest for an extension with a tab:

name = "My Dashboard"
version = "0.1.0"
icon = "assets/icon.svg"
readme = "README.md"

[zelos]
version = ">=26.0.10"

[app]
tab = "dist/tab/index.html"

[package]
paths = ["dist"]

For every field, including source = "external", see the Manifest Reference.

  • Vite should use base: "./" so assets resolve from zelos-app://...; the template already does
  • the initial host state arrives in handshake-ack.snapshot as info, theme, and workspace
  • after connect, the host pushes theme.changed and workspace.changed
  • bridge.hostVersion is the host app's version, or null before the handshake
  • bridge.invoke() exists, but extension authors should prefer the typed helpers
  • keep UI state inside the extension; the app may reload or remount a tab at any time