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
| Tool | Purpose |
|---|---|
canister.schema | The kernel-derived JSON Schema for a method |
canister.call_dialog | A consented canister call |
backend_calls.request / list | Reserve, release, and list persistent outbound grants |
apps.list / apps.describe | Installed-app metadata, marked untrusted |
apps.install_offer | The discoverable URL-only app/agent install offer |
endpoints.list | Currently live endpoints |
attachments.delegate | One short-lived attachment call under the current scoped invocation |
permissions.request | Request a session grant explicitly |
audit.list | The requesting app's own audit entries |
workspace.open_tile | The 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.
| Action | Accepted from | Notes |
|---|---|---|
tray.set_state | The exact registered background of an app declaring a tray | Payload is exactly { badge } — 0–9999 or null |
tray.dismiss | The exact live tray endpoint | Closes its own popover, destroying the iframe and endpoint |
clipboard.write_text | The exact focused tile, with transient user activation | Must be called synchronously from the click handler; ≤256 KiB; the trusted page performs the write |
connections.* | The exact live background endpoint only | Tiles cannot use them; credentials return only through the source-bound resident port |
ethereum_provider.* | The focused tile, with transient user activation | Returns a session id and bounded metadata, never the provider object |
vetkeys.* | Source-bound endpoints per declaration | Lifecycle requests require a focused tile; tray endpoints cannot derive |
app.state.publish | Any registered endpoint of the app | Forwarded only to other live endpoints of the same app |
agent.mode.request | The focused tile, with transient user activation | Requires a declared agent entrypoint |
agent.mode.status / .disable | Any 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.