Skip to main content

Message bus

Neutron has one kernel-regulated frontend message bus. It carries app-to-kernel, tile-to-background, background-to-tile, tray-to-background, and approved cross-app calls.

Operational traffic uses the private MessagePort established by the kernel's frame handshake. Window messages are used only to probe, announce readiness, and transfer that port; they are not a second application bridge.

The envelope

{
"type": "exec",
"id": 12,
"payload": {
"action": "tools.call",
"payload": {
"target": "app:notes:background",
"name": "notes_search",
"arguments": { "query": "roadmap" }
}
}
}

Responses contain exactly one of ok or error and the same numeric id. Ordinary envelope metadata must be JSON-compatible: the runtime rejects cyclic objects, class instances, typed arrays, undefined, non-finite numbers, functions, and payloads over 1 MiB. Calls have bounded timeouts and each source endpoint may have at most eight ordinary routed calls in flight.

Binary attachments

An attachment-capable tool advertises a neutron:attachments API-1 annotation. Its private-port sidecar may transfer at most one ArrayBuffer in each direction, up to 16 MiB, while arguments, results, and progress remain bounded JSON. The broker enforces 32 MiB in flight per endpoint and 64 MiB globally in the frontend broker realm.

Nested agent calls preserve provenance either in private invocation metadata or through the discoverable attachments.delegate tool. A delegation is source/session-bound, one-use, valid for 10 seconds, and limited to four pending tokens per endpoint and 64 globally.

Endpoints

kernel
app:<appId>:background
app:<appId>:tile:<tileId>:instance:<instanceId>
app:<appId>:tray:instance:<instanceId>

The kernel owns every id and derives caller identity from the registered contentWindow or its connected MessagePort. Tile query parameters are UI context only.

Liveness matters: background endpoints stay mounted while the authenticated workspace shell is; inactive workspace tiles and closed tray popovers are not live.

Exposing tools

exposeTool(
'notes_search',
{
title: 'Search Notes',
description: 'Search notes owned by this app.',
inputSchema: {
type: 'object',
required: ['query'],
properties: {query: {type: 'string'}},
additionalProperties: false,
},
outputSchema: {
type: 'object',
required: ['matches'],
properties: {matches: {type: 'array'}},
additionalProperties: false,
},
},
async ({query}, context) => ({matches: []}),
);

Input and output are validated at the endpoint and again at the kernel broker. Discovery always asks the live endpoint, so there is no stale kernel-side cache. removeExposedTool(name) unregisters.

Calling

const bus = createMsgBusClient();
const apps = await bus.listApps(); // bounded, untrusted metadata
const endpoints = await bus.listEndpoints(); // only currently live endpoints
const tools = await bus.listTools('app:notes:background');
const result = await callTool({
target: 'app:notes:background',
name: 'notes_search',
arguments: {query: 'roadmap'},
});

Progress

A handler may report JSON progress before its one authoritative result:

exposeTool('notes_import', options, async (args, context) => {
context.reportProgress({phase: 'indexing', completed: 12});
return {imported: 24};
});

await bus.callTool(call, {
timeout: 120,
onProgress(value) { renderProgress(value); },
});

Progress is bound to the exact source, target, session, and active callback. Each event is capped at 64 KiB and each request at 2,000 events. Late events and events from another source are ignored, and a failing consumer callback cannot change final completion.

Kernel tools

ToolPurpose
canister.schemaThe kernel-derived JSON Schema for a method
canister.call_dialogA consented canister call
backend_calls.request / listReserve, release, and list persistent outbound grants
apps.list / apps.describeInstalled-app metadata, marked untrusted
apps.install_offerThe discoverable URL-only app/agent install offer
endpoints.listCurrently live endpoints
attachments.delegateOne short-lived attachment call under the current scoped invocation
permissions.requestRequest a session grant explicitly
audit.listThe requesting app's own audit entries
workspace.open_tileThe single app-navigation path

Preapproved canister.query_self and canister.update_self use the separate attachment-aware same-canister wire; they are not ordinary discoverable kernel tool descriptors. A direct raw call action is not exposed to app iframes. Schemas are always derived by the kernel from the installed interface — an app cannot supply Candid or a package schema to a signed call.

Routing policy

  • Tile, tray, and background calls within one app are allowed.
  • Installed-app and live-endpoint summaries are discoverable.
  • Cross-app listing and invocation require a one-call or session grant.
  • Kernel tools apply their own validation and approval requirements.

Every routed call is recorded in a 200-entry in-memory audit ring with caller, target, tool, timestamp, status, duration, and bounded summaries.

State invalidation

Any registered endpoint may publish a same-app revision notification:

publishAppStateChange('notes', revision);
onAppStateChange('notes', ({topic, revision}) => refetch());

The payload contains no app id — the kernel derives the namespace from the registered source and forwards { topic, revision } only to other live endpoints of that app. This is private transport control, not a model-visible tool.

The event carries no state and grants no authority. Listeners compare the monotonic revision and re-fetch. Mount and reconnect still fetch a full snapshot, and a slow poll may remain as recovery, so dropped events cannot cause permanent divergence. Older asynchronous responses must not replace a view that already applied a newer revision.

Private actions

These travel on the same port but are not tools. They are absent from discovery, cross-app routing, agent tool selection, and the model-visible audit.

ActionAccepted fromNotes
tray.set_stateThe exact registered background of an app declaring a trayPayload is exactly { badge } — 0–9999 or null
tray.dismissThe exact live tray endpointCloses its own popover, destroying the iframe and endpoint
clipboard.write_textThe exact focused tile, with transient user activationMust be called synchronously from the click handler; ≤256 KiB; the trusted page performs the write
connections.*The exact live background endpoint onlyTiles cannot use them; credentials return only through the source-bound resident port
ethereum_provider.*The focused tile, with transient user activationReturns a session id and bounded metadata, never the provider object
vetkeys.*Source-bound endpoints per declarationLifecycle requests require a focused tile; tray endpoints cannot derive
app.state.publishAny registered endpoint of the appForwarded only to other live endpoints of the same app
agent.mode.requestThe focused tile, with transient user activationRequires a declared agent entrypoint
agent.mode.status / .disableAny registered endpoint of the app

The reason these are private rather than tools is uniform: making them discoverable would let a model or another app enumerate and attempt them, and connection responses in particular must never be observable through progress events.

Agent Mode

Agent Mode extends this bus rather than adding another. An app may declare exact resident tools as agent entrypoints; after the owner enables one exact app version and entrypoint, a focused tile can start a turn during transient user activation. A tray endpoint cannot start a turn or receive a delegated call.

The kernel gives the resident handler a scoped client on the tool context:

exposeTool('agent_run', options, async (args, context) => {
const apps = await context.kernel.listApps();
return context.kernel.callTool({
target: 'app:notes:background',
name: 'notes_search',
arguments: {query: String(args.query)},
});
});

context.kernel carries an opaque invocation capability in private transport metadata — never in tool arguments, schemas, progress, results, or discovery. Each routed child gets a fresh endpoint- and session-bound capability, and the kernel invalidates it when the handler returns, times out, disconnects, is cancelled, or when the grant, app version, authorization, or endpoint session changes.

:::caution Provenance cannot be laundered Any handler making nested calls must use its own context.kernel. Using a global bus client while that app has an active descendant invocation returns SCOPED_CONTEXT_REQUIRED, and the kernel does not fall back to an owner dialog. The check is app-wide, so moving the request to another tile or the resident frame cannot escape provenance. :::

context.signal aborts when the owner stops the root, so cooperative handlers can stop local work — but already-issued remote side effects cannot be rolled back.

Policy failures preserve a machine-readable code and optional retryAfterMs: OWNER_REQUIRED, SCOPED_CONTEXT_REQUIRED, INVOCATION_INVALID, AGENT_CONSENT_DENIED, AGENT_CONSENT_TIMEOUT, AGENT_CONSENT_LIMIT, AGENT_MODE_REVOKED, AGENT_MODE_LIMIT, UI_BUSY.

Background lifecycle

One hidden, non-interactive iframe per registry entry declaring a background, with at most 32 resident app frames in an installation. Its identity binds the app version, installation scope, runtime generation, background path, and security/origin mode, so a replacement or authority change reloads the process. Removing the registry entry, logging out, losing authorization, or unmounting the workspace shell closes its port and removes the endpoint.

Background code may use a dedicated worker for computation. Service workers are deliberately not the resident-process primitive: their event-driven lifetime and fetch interception are a poor fit for untrusted long-lived state.