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" }
| Surface | Count | Lifetime |
|---|---|---|
| Tile | Many definitions, many live instances | Per open window |
| Background | 0 or 1 | Mounted while the workspace shell is |
| Tray | 0 or 1, requires background | Only 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().
With a consent dialog
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.
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
| Helper | Requirement |
|---|---|
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 helpers | Background 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 |
Navigation
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.
A view token is untrusted UI navigation. Never perform persistent, destructive, signed, or backend work because you received one.
Limits to design around
| Limit | Value |
|---|---|
| Message-bus JSON payload | 1 MiB |
| Ordinary in-flight tool calls per live endpoint | 8 |
| Progress event size / count | 64 KiB / 2,000 per request |
| Clipboard text | 256 KiB of UTF-8 |
| Self-call binary data | 1,900,000 bytes aggregate per direction |
| Self-call binary leaves | 512 per direction |
| Self-call non-binary metadata | 64 KiB |
| Self-call nesting / elements per container | 32 / 4,096 |
| Audit ring | 200 entries, in memory |