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¶
The name must be kebab-case: lowercase letters, digits, and hyphens. The command creates a my-app-extension/ directory, runs git init with an initial commit, and installs the project into the local agent with install-local. It skips the install when --host points at another machine.
Options:
zelos extensions create my-app-extension --type app \
--author "Jane Doe" \
--description "Custom dashboard for device health" \
--output ~/src \
--template-ref v0.1.0
| Option | Effect |
|---|---|
--author, --email, --github, --description |
Project metadata in extension.toml, package.json, and the README. |
-o, --output |
Parent directory for the project. Default: the current directory. |
--template |
Template variant. react, the default, is the only app template today. |
--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 git init and the local install. |
The project is a React and Vite app. src/main.tsx wraps the app in ZelosBridgeProvider from @zeloscloud/app-extension-sdk/react.
In the Zelos App, Create Extension in the Extensions view does the same. Pick App Extension.
Use the current SDK¶
The template pins @zeloscloud/app-extension-sdk to ^0.1.0. On a 0.x package that range stays on 0.1.x, which lacks most of the bridge surface on this page. Update the dependency after you create a project:
| 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 |
This page documents 0.3.0.
SDK 0.3.0 declares Node 22.12 or later in its engines field. The template pins Node 20 in .nvmrc and package.json, so npm prints an EBADENGINE warning. Move both to Node 22.12 or later to clear it; the generated CI workflows read .nvmrc.
Develop standalone¶
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()ortrace.init()need a stubbedMockBridge.setInvokeHandler(...)outside Zelos
Develop inside Zelos¶
For fast iteration:
To pick up a reinstalled build, close and reopen the APP tab (or restart the app). App extensions have no in-place hot reload.
Rebuild and reload an app extension¶
App extensions are not process-managed like agent extensions. The usual app workflow is:
- build the web assets
zelos extensions install-local <path>- 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.
Sandbox and network restrictions¶
Embedded app extensions 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. Extensions loaded from an externalhttps://URL run unsandboxed, in their 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 |
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: maxRows 100k, width 10k, signals 512, path keys 64, cells (signals × width) 1M, span 1 year |
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 |
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 |
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 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 from an app extension¶
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 a window.
const latest = await query.liveQueryLatestMulti(bridge, {
agentSignals: { [address]: paths },
start: "2026-01-01T00:00:00Z",
end: "2026-01-01T00:00:30Z",
});
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 (UNKNOWN_METHOD,INVALID_PARAMS,LIVE_MODE_REQUIRED,AGENT_NOT_CONNECTED,USER_CANCELLED,NO_HANDLER,NO_ORGANIZATION); switch on it instead of matching message text - bulk timeseries reads are host-clamped: you can request fewer rows/signals/width, never more
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 payloadrestart()andinstallLocal()are not exposed on this surface- 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 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
[host.app].entrypoints 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 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¶
just package runs npm run build, then npm run package. npm run package only runs zelos extensions package ., so build first when you call it directly.
The generated extension.toml includes the [package] section that packaging requires:
Use zelos extensions package --list . 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 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"]
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 extension-owned UI state inside the extension; APP tabs may be reloaded or remounted across the desktop app lifecycle
Related pages¶
- Shared extension concepts: Develop Extensions
- Manifest Reference
- Package and Publish Extensions
- Official app template: https://github.com/zeloscloud/zelos-extension-templates/tree/main/app/react
- SDK package:
@zeloscloud/app-extension-sdk