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¶
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:
- official templates: https://github.com/zeloscloud/zelos-extension-templates/tree/main/app/react
- SDK package:
@zeloscloud/app-extension-sdk
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:
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:
- 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.
Security note¶
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-originonly; 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 addressactions.execute()does not assumelocalhost; pass the target agent explicitly- when
paramsis omitted, the SDK sends{}rather thanundefined
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 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¶
That builds the app and delegates archive creation to:
The generated extension.toml includes a [package] section that controls which files are included in the archive:
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 fromzelos-app://... - the initial host state arrives in
handshake-ack.snapshotasinfo,theme, andworkspace - after connect, the host pushes
theme.changedandworkspace.changed bridge.invoke()exists, but extension authors should prefer the typedactions,extensions, andtracehelpers- 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¶
- Shared extension concepts and broader reference: Develop Extensions
- Official app template: https://github.com/zeloscloud/zelos-extension-templates/tree/main/app/react
- SDK package:
@zeloscloud/app-extension-sdk