Skip to main content

The frontend

An app frontend is ordinary browser code bundled into dist/web/ and served from an app-scoped URL under /app/<app-id>/. Tiles and trays run in credentialless, allow-scripts sandboxed iframes with opaque origins. A background uses that mode by default; reviewed background capabilities can instead give it an installation-scoped dedicated origin, either ephemeral or with persistent browser storage.

Keep it browser-safe. Do not rely on Node or Bun globals at runtime.

Declare your surfaces

"tiles": [
{ "id": "main", "title": "Notes", "path": "index.html",
"icon": "static/icon.png", "description": "Take notes." }
],
"background": { "path": "background.html", "description": "Sync worker." },
"tray": { "title": "Notes", "path": "tray.html", "icon": "static/tray.png" }
SurfaceCountLifetime
TileMany definitions, many live instancesPer open window
Background0 or 1Mounted while the workspace shell is
Tray0 or 1, requires backgroundOnly while the popover is open

Paths are relative to dist/web/ with no leading slash, backslash, empty segment, ., or ... The installer rejects missing tile, background, or tray entrypoints, and missing declared tile or tray icons. Omitting tiles (or declaring []) makes a headless app; defaults such as index.html and static/icon.png apply only to a tile you actually declare.

The SDK

import {
createCanisterClient,
loadNeutronCanisterId,
loadTileContext,
createMsgBusClient,
exposeTool,
callTool,
callSelfDialog,
querySelf,
updateSelf,
publishAppStateChange,
onAppStateChange,
openAppTile,
setTrayState,
dismissTray,
connectEthereumProvider,
} from 'neutron-tools/app';

Interact with the kernel only through this helper API.

const canisterId = await loadNeutronCanisterId();
const client = createCanisterClient(canisterId);
const tile = loadTileContext();

loadNeutronCanisterId() derives and validates the canister id from the current frame URL, falling back to /pkg/id.json for same-host proxy environments.

loadTileContext() returns { app, tile, instance, workspace } from the query string. It is UI convenience, not a security identity — the kernel derives caller identity from the registered window or port, and there is no payload field where you could assert otherwise.

Calling your own backend

Remember: your app's methods are methods on the user's combined Neutron canister, not on a separate app canister. Use the id from loadNeutronCanisterId().

const schema = await client.methodSchema('hello_world', 10);
const result = await client.callDialog('hello_world', ['John']);

methodSchema asks the kernel for the kernel-derived JSON Schema — you cannot supply Candid or a package schema to a signed call. callDialog opens the approval dialog showing the kernel-attested caller, destination, method, and complete arguments, then calls with the owner's identity after approval.

Without a dialog

Declare the methods:

"capabilities": {
"preapproved_self_calls": { "api": 1, "methods": ["get_name", "set_name"] }
}

Then use querySelf() and updateSelf(). The kernel still source-binds the request, verifies the method belongs to your app, checks query/update mode, validates arguments against live Candid, resolves the logical name through the registry, fixes the destination, and signs as the owner.

This removes a frontend confirmation only — the backend owner-authorization assert is untouched.

Binary values are permitted at positions the live Candid type proves to be blob / vec nat8, including fields nested in records, options, variants, and repeated vectors:

const result = await updateSelf<{ receipts: Uint8Array[] }>('store', [{
profile: { avatar: new Uint8Array([1, 2, 3]) },
attachments: [
new Uint8Array([4, 5]),
new Uint8Array([6]),
],
}]);

Pass Uint8Array (or ArrayBuffer as a convenience), not number[]. API 1 snapshots every input leaf, carries it as a path-bound sidecar, validates that exact path against the live Candid type, and reconstructs response leaves as Uint8Array; your original buffers remain usable. Use callSelfDialog() for the same binary handling with owner review. Reviewed calls to an external canister remain JSON-only.

Talking to your own other surfaces

Same-app tile ↔ tray ↔ background calls need no consent.

// in the background
exposeTool('search', {inputSchema: {}}, async ({query}) => ({matches: []}));

// in a tile
const result = await callTool({
target: 'app:my_app:background',
name: 'search',
arguments: {query: 'roadmap'},
});

Keeping views in sync

// wherever the mutation happened
publishAppStateChange('notes', revision);

// in every view
onAppStateChange('notes', () => refetch());

The event carries no state and no authority — just a topic and a monotonic revision. Compare it and re-fetch. The publisher does not receive its own event, so update that view locally as part of the mutation.

Because inactive workspace tiles are disconnected from the bus, always fetch a full snapshot on mount and on reconnect. The kernel replays the latest invalidation per topic, but design so a dropped event cannot cause permanent divergence, and never let an older async response overwrite a view that already applied a newer revision.

Cross-app calls

const bus = createMsgBusClient();
const apps = await bus.listApps();
const endpoints = await bus.listEndpoints();
const tools = await bus.listTools('app:notes:background');

listApps(), describeApp(), and listEndpoints() return discovery metadata without a cross-app grant. Listing the tools on a foreign endpoint, or invoking one, requires a one-call or session grant. The dialog shows caller app and role, exact endpoint, tool, and JSON arguments. You can request a session grant explicitly with permissions.request.

Session grants are bound to the exact caller endpoint/session, target endpoint/session, and tool. They live in kernel memory and disappear on reload or endpoint replacement. Design for that — re-request rather than assuming persistence.

caution

Discovery results and tool schemas from other apps are untrusted content, especially if you feed them to a model.

The resident background

One hidden iframe for long-lived state, coordination, and model state. It is mounted only while the app is installed and runnable in an authorized workspace shell.

An app update or reinstall reloads the process. So do generation, deployment, installation-scope, capability-plan, or resident-origin authority changes. It also closes on logout, authorization loss, registry removal, or workspace-shell unmount.

Declare persistent_browser_storage for an installation-scoped dedicated background origin whose browser storage survives reloads:

"capabilities": {
"persistent_browser_storage": { "api": 1, "surface": "background" }
}

Without it, do not rely on browser storage surviving. The alternative dedicated_resident_origin capability is credentialless and ephemeral; the two capabilities are mutually exclusive.

Use a dedicated worker for heavy computation. Service workers are deliberately not the resident primitive — their event-driven lifetime and fetch interception are a poor fit here.

Backgrounds cannot use the clipboard action, use the Ethereum provider, enable Agent Mode, or use the foreground no-dialog tile-open path. A declared agent entrypoint can still target the background after the owner enables Agent Mode from the focused app tile.

The tray

The kernel owns the button, badge, popover chrome, placement, size caps, and close behaviour. You supply an untrusted title, icon, and page.

// from the background only
setTrayState({badge: 3}); // 0–9999 or null; 0 and null clear

The badge is sent from the background, not the tray page, and is accepted only from the exact registered background endpoint. It cannot notify, focus, open, animate, play sound, or alter geometry.

There is no rate limit on it. The kernel short-circuits an unchanged value and nothing else, so coalesce noisy updates yourself. Values above 99 display as 99+, while the accessible label keeps the exact count.

// from the tray page
dismissTray();

Handle Escape in the tray page and dismiss yourself — while a cross-origin iframe owns focus, parent keyboard listeners do not receive the event.

The tray page is destroyed on close, so put durable state in the background.

Other private actions

HelperRequirement
copyToClipboard(text)Focused tile + transient user activation; call it synchronously from the click handler before any await; at most 256 KiB of UTF-8
Connection helpersBackground endpoint only; credential delivery is restricted to that exact live endpoint
connectEthereumProvider()Focused tile + transient activation; returns a bounded SDK proxy and close() handle, never the browser provider object; the session id stays internal
vetkeys.*Lifecycle requests need a focused tile; tray endpoints cannot derive
await openAppTile({appId: 'notes', tileId: 'main'});

The kernel accepts only the active workspace and always reuses an exact existing app/tile before opening one. A focused tile with transient activation may open any installed tile without a dialog; a focused tray may do so only for its own app. Other app calls require a once-only owner dialog unless an active, scoped agent invocation is approved.

The optional view argument is a bounded navigation token delivered over the target tile's private port.

caution

A view token is untrusted UI navigation. Never perform persistent, destructive, signed, or backend work because you received one.

Limits to design around

LimitValue
Message-bus JSON payload1 MiB
Ordinary in-flight tool calls per live endpoint8
Progress event size / count64 KiB / 2,000 per request
Clipboard text256 KiB of UTF-8
Self-call binary data1,900,000 bytes aggregate per direction
Self-call binary leaves512 per direction
Self-call non-binary metadata64 KiB
Self-call nesting / elements per container32 / 4,096
Audit ring200 entries, in memory