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¶
--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:
- Runs
uv syncwhen the project has an agent runtime. - Runs
git initand makes an initial commit. - Installs the project into the local agent with
install-local. It skips this step when--hostpoints 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:
| 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¶
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()ortrace.init()need a stubbedMockBridge.setInvokeHandler(...)outside Zelos
Develop inside Zelos¶
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:
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 tohttp:/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 externalhttps://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 addressactions.execute()does not assumelocalhost; pass the target agent explicitly- when
paramsis omitted, the SDK sends{}rather thanundefined timeoutMsis the host-side timeout; omit it to use the action's declared timeout, or pass0for noneactions.schema(bridge, { agent, action })returns the action's input schema and UI schema as JSON strings, plusdefaultTimeoutMs; parse them withJSON.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 withLIVE_MODE_REQUIRED(trace-keyed methods run in any mode) - rejected invokes carry a machine-readable
.codefrom theAPP_BRIDGE_ERROR_CODESregistry; switch on it instead of matching message text:UNKNOWN_METHOD: the host has no handler for the methodINVALID_PARAMS: params failed host-side validation or a capLIVE_MODE_REQUIRED: the call needs LIVE workspace modeAGENT_NOT_CONNECTED: the target agent is not in the connected setUSER_CANCELLED: the user dismissed a host dialog, such as a save promptNO_HANDLER: the host registered no invoke handler at allNO_ORGANIZATION: no signed-in user or organization for the host to act asPANEL_ONLY: apanel.*method was called from a tabUNKNOWN_SUBSCRIPTION:panel.unsubscribeorpanel.acknamed an id the panel does not holdSUBSCRIPTION_LIMIT: a new subscription when the panel already holds 8, or its other subscriptions hold its whole cell budgetINVALID_STATE: the host cannot do this now or for this document, and the message says whyRATE_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'spanel.ackpasses 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 payloadstart(),stop()andreinstall()need an extension with an[agent], like the CLI commandsinstallLocal()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 createdfinalize()flushes and closes the writerabort()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]tabentry points at a built HTML file - rebuild before
install-localand make sure the app still mountsZelosBridgeProvideror callsconnectBridgeTransport()
"Unsupported app bridge protocol version"
- the iframe and host disagreed on
protocolVersion - upgrade
@zeloscloud/app-extension-sdkor regenerate from a current template, then runjust buildand 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,stopand a plainreinstall(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 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:
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 fromzelos-app://...; the template already does - the initial host state arrives in
handshake-ack.snapshotasinfo,theme, andworkspace - after connect, the host pushes
theme.changedandworkspace.changed bridge.hostVersionis the host app's version, ornullbefore the handshakebridge.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
Related pages¶
- Shared extension concepts: Develop Extensions
- Manifest Reference
- Package and Publish Extensions
- Template source: https://github.com/zeloscloud/zelos-extension-templates/tree/main/parts/app-react
- SDK package:
@zeloscloud/app-extension-sdk