Skip to content

How to Develop App Extensions

App extensions are sandboxed web projects that open in desktop APP tabs. They are the right choice 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-app-extension --type app

Optional metadata and template controls:

zelos extensions create my-app-extension --type app \
  --author "Jane Doe" \
  --description "Custom dashboard for device health" \
  --template react \
  --template-ref v1.0.0

The created project is based on:

Develop standalone

cd my-app-extension
npm install
npm run dev

Running the app in a normal browser tab uses 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

npm run build
zelos extensions install-local .

For fast iteration:

npx vite build --watch

Then reload or reopen the APP tab in Zelos.

Lifecycle note

App extensions are not process-managed like agent extensions. The usual app workflow is:

  1. build the web assets
  2. zelos extensions install-local <path>
  3. reopen or reload the APP tab

Agent lifecycle commands such as zelos extensions start, stop, restart, and reinstall do not apply to app extensions.

The SDK extensions.start() / extensions.stop() helpers are different: they go through the host bridge and are meant for installed agent-hosted extensions on an agent. They are not a shortcut for reloading the current app extension.

Security note

Embedded app extensions 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 only; no form submissions or popups

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
actions.list() / actions.execute() Discover and run agent actions omitting agent in list() fans out across connected agents; execute() requires LIVE workspace mode, a connected target agent, and an explicit agent
extensions.list() / extensions.start() / extensions.stop() Inspect or control installed extensions on an agent start() / stop() use the host's saved config; there is no config injection or restart() helper in v1
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 from an app extension

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<{ task_id: string }>(bridge, {
  agent: "localhost:2300",
  action: "can/send_raw",
  params: {
    bus: "demo",
    can_id: "0x100",
    data: "01 02",
  },
  timeoutMs: 5000,
});

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

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

Manage installed extensions from an app extension

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: "zeloscloud.zelos-extension-can",
});

Important constraints:

  • this surface is for installed extensions on an agent, not for reloading the current app extension
  • extensions.start() uses the host's last-saved config; there is no v1 API for supplying a fresh config payload
  • the desktop CLI still rejects zelos extensions start|stop|reinstall <app-extension-id> for app extensions themselves

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 [host.app].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 app-extension template, then rebuild and reinstall locally

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

"Operation not supported for app extensions"

  • you are likely using an agent lifecycle command (zelos extensions start|stop|reinstall) on an app extension id
  • use the build -> install-local -> reopen/reload flow for app extensions instead

Package and release

npm run package

That builds the app and delegates archive creation to:

zelos extensions package .

The generated extension.toml includes a [package] section that controls which files are included in the archive:

[package]
paths = ["dist"]

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

Bridge and manifest essentials

A complete manifest for an app extension:

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

[host]
type = "app"

[host.app]
kind = "web_app"
entry = "dist/index.html"

[package]
paths = ["dist"]
  • Vite should use base: "./" so assets resolve from zelos-app://...
  • 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.invoke() exists, but extension authors should prefer the typed actions, extensions, and trace helpers
  • optional host context is available through SDK hooks when your app needs it
  • keep extension-owned UI state inside the extension; APP tabs may be reloaded or remounted across the desktop app lifecycle

More detail