Session dashboards are persistent widget boards attached to a session. This reference covers their ownership, sandbox and capability boundaries, storage, and Gateway protocol. For the operator workflow, see Session Dashboards. For the widget authoring API, see Show widget.

Vision

A dashboard turns the session into a workbench: the agent renders interactive widgets, the user pins them onto a persistent board, and the board can occupy either the main area or a side panel alongside chat. Focusing the main view gives it the full task area. The board and conversation remain part of the same session.

Principles:

Concepts

Concept Definition
Session (thread) Existing gateway session, keyed by stable sessionKey. Owned by an agent.
Board The widget board of one session. Exists iff the session has widgets/tabs. Survives /new//reset (attached to sessionKey, not the transcript).
Tab A presentation page of a board: which widgets and their arrangement. Boards start with one implicit tab.
Widget Named content cell owned by the session: a native report, HTML/JS, MCP App, or plugin widget. Addressed as sessionKey + name.
Capability manifest Per-widget declaration of reach: data (read bindings), actions (allowlisted verbs), prompt (send to session), net (allowed origins).
Pin (widget) Moving a transcript widget onto the session's board (user affordance or agent tool arg). Unpin removes it from the board.
Pin (session) Root sessions and ordinary Home-linked dashboard sessions can be pinned; spawned, subagent, and nested-child sessions reject pin requests. Subagent runs appear in session transcripts, outside sidebar navigation. Opening a pinned session restores that browser's saved task layout.

UX flows

Interaction tiers

  1. State events (default). Widget UI interactions the model should know about but not respond to. bridge.emitState({...}) appends a structured session notice (same mechanism as group-activity notices). No agent turn is started. The model sees accumulated notices on its next run.
  2. Prompts (explicit talk). bridge.sendPrompt(text) — requires user activation, and sends a visible user message into the owning session. Rate-limited. Each send is user-confirmed unless the widget holds the prompt capability grant.
  3. Automation. bridge.runAction(name, args) — fires a manifest-declared action. Initial verb set: cron.trigger (run an existing cron job now) and binding.refresh. Cron jobs already run in visible, isolated run-sessions and can use a cheaper model: that is the "small model powers the widget" path. No hidden sessions anywhere.

Widget model and hosting

HTML/JS widget content is authored by the agent (typically via show_widget), wrapped in the standard document shell (CSP meta, size reporter, bridge bootstrap), and rendered in an opaque-origin content iframe. In the Control UI's default scripts embed mode, both inline and board widgets use the dedicated-origin sandbox proxy described below.

Widgets host content; MCP apps are one content kind

The widget is the Apus primitive: the named, pinned, sized, session-owned board cell with a grant record. What renders inside it is a content kind:

MCP apps do not define the widget model. Widgets gained the ability to host them. Identity, placement, pinning, grants, and the author-facing API stay Apus's — so show_widget code stays as short as it is today and never needs to know the MCP Apps spec exists.

Registered kinds use a small runtime Plugin SDK seam. A registration owns the agent-facing kind name, source validation, capability-scoped renderer resources, and document-body composition. The Gateway validates source again at board.widget.put, stores it in the existing generic plugin descriptor envelope, and composes the framed document only after a ticketed board read. This keeps stored source out of board snapshots and avoids a database CHECK or schema-version change. Disabled plugins are absent from the registry, so new puts fail with an enable-and-retry error and existing cells render as disabled.

The A2UI implementation composes a small document that references its renderer bundle. Ticketed board documents use the capability-scoped Gateway asset route. Inline documents load the same public static renderer from the sandbox origin. Core adds the same CSP, theme bridge, size reporter, and private-port host bridge used by HTML widgets. v0.8 and v0.9 use separate renderer bundles because their Lit custom elements share tag names but their processors and action contracts differ.

A registered kind can expose public static renderer bytes through resources.readPublicResource(path). The isolated listener serves only exact registered resources.paths, only for GET or HEAD, and rechecks the active plugin registry after reading. It does not proxy Gateway routes, credentials, or data. Canvas opts in its two A2UI bundles. This grants no widget network or host-tool capability.

Shared hosting infrastructure:

The outer proxy runs on a different origin from both the Control UI and the Gateway. Its iframe allows allow-same-origin so the host can authenticate proxy messages against that dedicated origin. The proxy puts widget bytes in an inner srcdoc iframe with allow-scripts allow-forms, without allow-same-origin. Widget code therefore has neither application-origin access nor the proxy's origin. Inline views adopt only the wrapper's private prompt channel. Dashboard views initialize their separate ticket-bound bridge.

The public proxy shell uses a URL fingerprint of its HTML and security headers. Browsers can cache that exact version for repeated widget mounts; changing the shell or its policy changes the URL. Unversioned or mismatched requests remain uncached. This cache contains no widget documents, credentials, or view tickets.

The shared loader fetches board HTML while the sandbox proxy starts, then delivers it only after that exact proxy reports ready. Dashboard widgets keep a themed loading placeholder until the proxy confirms that the current inner document has loaded. This rendering signal is separate from HTML delivery and bridge initialization, so an empty widget can finish loading without reporting a positive content height. Focused dashboard routes reuse an already resolved session key and agent owner to avoid a second session lookup.

Mounted widgets retain their loaded document across presentation changes and ticket renewal. Inline views share concurrent reads of the same document only within one client and connection generation. Each view still owns its own sandbox and prompt channel. Managed [embed ref="..."] previews use that authenticated path whenever their effective sandbox policy permits scripts, including the default with no explicit sandbox field. Explicit strict previews remain script-free. There is no completed-document cache: Canvas permits replacing named document IDs, so a remount reads the current source again. A transient disconnect keeps an already-mounted inline iframe and its local interaction state, but retires pending results and server-action authority from the previous connection. Reconnect revalidates the document: unchanged bytes preserve the frame, while changed content or identity replaces it. This is in-memory presentation retention, not a durable document cache or permission to replay widget actions.

Website widgets

session:website uses the existing native widget descriptor with validated props: { url }. The board store owns persistence, revision, same-name updates, and deletion; no schema, protocol method, or configuration change is needed. Older renderers show the ordinary unavailable-widget state for this unknown kind.

The Control UI renders an HTTPS website in its own sandboxed frame, with scripts, same-origin website storage, forms, and popups enabled. It rejects URLs containing userinfo and refuses the Control UI and connected Gateway hostnames across ports, since browser cookies share a hostname boundary. Apus injects no Gateway token, capability ticket, message bridge, or parent-navigation permission. Its own HTTP headers and browser cookie rules still apply. The Gateway never fetches or relays its content. Passive gallery previews never mount the frame.

Website widgets do not use the HTML document sandbox or its capability grants. That owner continues to block descendant frames. A full-width website can fill the existing expanded dashboard; shared grids retain their ordinary sizing. See Show a website fullscreen.

Browser dashboards

The Browser plugin advertises browser:dashboard to operators with Browser access. Its saved props contain an HTTP(S) URL and optional local managed profile. The native renderer reuses the Browser panel's streamed viewport and input path, fixed to the dashboard's exact browser target. Ordinary Browser preview results do not replace this dashboard presentation.

The board owns the definition and insertion identity. Native plugin widgets store an internally generated pluginInstanceId in existing manifest metadata and expose it as the optional instanceId field. Same-owner updates preserve it; remove/recreate rotates it. Older native rows receive an identity on their next put, with no read-time backfill. Document widgets retain their existing per-put view and grant generations; those are a different lifetime.

Browser owns the tab association and stopped state in its existing browser.session-tabs SQLite namespace, including the browser/profile fingerprints used for safe cleanup. Concurrent views and agent calls share materialization for one board instance. Stop before the first open persists a typed intent in the same store without a browser target or fingerprints; it survives reload and is retired after successful Resume or definition removal. Calls resolve the current definition and revalidate the association through the existing browser route/profile admission before acting. A saved target ID alone is not authority.

The existing browser.request transport carries the dashboard identity for Control UI requests. The model-facing browser tool accepts the widget name as dashboard; dashboard remains the board authoring/layout tool. Stop and Resume update Browser's lifetime state without rewriting board props or overwriting a concurrent definition edit. UI unmount releases only the stream. Browser publishes lifetime invalidations through its existing plugin event service, so agent Stop/Resume updates the visible dashboard. Board-change and session-deletion notifications request reconciliation; the existing cleanup cycle also reconciles removed/replaced definitions when ordinary idle cleanup is disabled. Conversation reset preserves the board and its valid tab association.

Native data reports

session:report is an advertised core widget kind. Its validated props contain text, metrics, tables, bar or line charts, and HTTP(S) links. The Control UI renders these blocks directly from the board snapshot, without a document fetch, iframe, capability ticket, or widget bridge. This pure renderer can also appear in passive dashboard gallery previews. That exception does not enable arbitrary native plugin widgets in previews.

Both authoring paths use the same validator in src/messaging/boards/board-report.ts:

The report uses the existing generic plugin descriptor and JSON props storage. There is no new storage schema or protocol method. Its complete JSON is capped at 8KB and 24 blocks. Reports accept no capabilities, executable actions, HTML, CSS, media, network reads, or RPCs. show_widget report mode is dashboard-only, with no inline, device-panel, or channel presentation. Its structured report field cannot be mixed with kind, widget_code, capabilities, or device presentation. The existing source-kind enum and plugin registrations are unchanged.

Same-name report updates retain the session:report content owner. Converting an HTML widget requires an explicit remove followed by creation, with no automatic migration. Prefer reports for pinned data summaries. Keep HTML for arbitrary interactive or inline content. See the report schema and example.

Plugin capability declarations

Enabled plugins can extend the widget host through dashboard.dataBindings and dashboard.actionVerbs in apus.plugin.json. Plugin-local ids become grant names prefixed by the plugin id, such as workboard.cards.list and workboard.dispatch. % and . in the plugin-id segment are escaped so a different plugin/local-id split cannot inherit the same persisted grant. During plugin registration, Apus verifies that every binding targets an RPC registered by the same plugin with operator.read and every action targets one with operator.write. Invalid declarations fail the plugin load. The validated registry is rebuilt only with plugin lifecycle changes, while widget grants remain per-widget and byte-and-revision-bound.

show_widget uses this validated active registry for bounded author discovery, including complete descriptions and action parameter schemas where available. It never loads plugins merely to describe their dashboard capabilities.

Authenticated GitHub reads

Core's existing GitHub identity and HTTP owners serve github.actions.runs through board.data.read. The closed parameter contract constructs only the repository or workflow run-list operation at api.github.com. Both credential selection and transport stay bound to github.com, even when project discovery uses a configured Enterprise host. Enterprise credentials are never used for this public-host capability. Authorization requires the exact normalized github.actions.runs:<owner>/<repo> tool grant. Network-origin grants never supply GitHub identity authority. Approval discloses that Actions metadata, including private repository data accessible to the agent, is shared with the widget/session audience.

Author guidance is conditional on a usable connected agent identity, not a tool-construction-time check. board.widget.put verifies and revalidates that identity before saving HTML (including materialized Canvas documents) or registered widgets declaring this host capability. The same preparation owner serves pinning and reads, including source-config preview-credential scrubbing and OAuth refresh. Caller cancellation and session mutation authorization are rechecked before persistence. Failure leaves existing content and grants intact. MCP App tool names use their own contract and do not trigger this preflight.

The board capability owner carries the canonical agent/session privately and rechecks the live Gateway, ticket generation, widget revision and grant across awaits for both data and action paths. GitHub selects the agent override, System, or native identity using the existing credential owner and OAuth refresh service. Read authority additionally revalidates selection and credential rotation before fetch and before returning data. Native gh auth token reads can reuse a successful lookup for up to 60 seconds, so host login, logout, and account switching can take that long to appear. Environment tokens, managed credentials, and caller authority remain live checks. This does not change the personal publication broker or its admitted credential-snapshot semantics.

Authenticated reads never use preview authentication or anonymous retry. Redirects are refused. Only this Actions read permits an upstream body up to 1 MiB. Other GitHub JSON callers retain their 256 KiB default. The owner validates and projects at most 30 runs into a small response, without raw repository objects or secrets. A Gateway-local cache holds at most 32 successful results for 30 seconds. At most 32 concurrent callers can prepare or await reads. The shared transport caches only validated projections under its captured credential scope. This internal cache write is not delivery to a widget. Every caller, including the initiator, followers, and cache hits, revalidates its own live authority before delivery. Removing one caller does not invalidate another authorized caller's result, and failed transport reads are not cached as success. See the authoring contract and example.

Modeled residual: WebRTC data channels

The sandbox CSP emits the proposed webrtc 'block' directive, but Chromium's current CSP directive set does not implement it. Scriptable widgets can therefore use WebRTC data channels for egress without CSP enforcement of that directive. This residual also applies to inline chat widgets and the MCP Apps host.

Accepted tradeoff: Apus does not gate scriptable widgets on this residual. Widget content gains access to sensitive Apus data only through policy-granted, byte-frozen data bindings, and the sandbox Permissions Policy blocks camera and microphone access.

Inline and board widgets enable a DOM API guard before widget code runs. It removes same-realm WebRTC constructors and blocks common ways to create descendant browsing contexts with fresh constructors. This reduces exposure but remains best-effort defense-in-depth, not an isolation or authorization boundary. It does not eliminate the accepted residual. The guard is implemented in src/agent/agents/sandbox-host.ts and enabled by the Canvas and board view owners.

Transcript display: one widget card

Inline HTML and MCP App previews share a widget-card renderer that dispatches on content kind. Eligible previews expose the same Pin to dashboard affordance. Generated widget names let the card recognize an existing pin and avoid offering a duplicate. The renderer and pin paths live in ui/src/pages/chat/components/widget-card.ts.

Widgets compose through grid adjacency: an agent-authored widget and an MCP App can occupy neighboring cells on one tab. Each keeps its own sandbox and bridge identity. An MCP App must not render inside an agent-authored iframe, where nesting would compromise bridge identity and allow overlays over granted app UI.

Server-sourced widgets (pinned MCP apps)

With the unified host, pinning a third-party MCP app is just a widget whose content is fetched from the server instead of stored: board_widgets keeps the descriptor (serverName, toolName, uiResourceUri, originating toolCallId + sessionKey) instead of HTML bytes, and the board re-mints the view lease past the chat-turn 10-minute TTL (re-fetching the ui:// resource on staleness). Chat inline MCP app views have the same Pin to dashboard affordance as agent widgets. Re-opened views are read-only today by design. Pinned apps that should stay interactive get a durable grant over the server's app-visible tools (explicit allowlist shown to the operator on pin), decoupled from the minting run. Ungranted pins can render their fetched App HTML but cannot call tools or access the same-server resource bridge. Pins belong to the originating session's board. Cross-session pinning is not supported.

WorkBoard integration

The WorkBoard integration program keeps cards and boards plugin-owned. It stitches dispatched cards back to their session boards through the existing sessionKey and runId. It exposes WorkBoard feeds and dispatch through plugin-declared bindings and actions. It composes those results with the existing html and mcp-app widget kinds instead of introducing a WorkBoard-specific widget type.

Layout: fluid grid

12 columns, fixed row height, auto-compacting (gravity-up, push-aside on drag — gridstack semantics, implemented natively). Grid math stays pure and DOM-free. Widget layout state per tab: { name, w (1-12), h (rows) } plus order. Agent vocabulary:

Data model (per-agent DB)

Board state lives in agents/<agentId>/agent/apus-agent.sqlite:

The canonical table definitions, constraints, and indexes are in src/platform/state/apus-agent-schema.sql. The board schema ensure/repair path is src/platform/state/apus-agent-board-schema.ts. Runtime reads and writes are owned by src/messaging/boards/sqlite-board-store.ts. See Database schemas for schema versions, migration and downgrade rules, and the review checkpoint for material storage changes. Do not use a copied SQL sketch as the schema contract.

Ordinary disk snapshots and widget-document reads use the existing session history read worker. Mutations borrow the canonical per-agent SQLite writer connection, where the Boards backend checks current caller authority at transaction entry and commit. Committed changes invalidate the host's exact session projection before the mutation returns; cleanup failures do not turn a completed write into a retryable failure. Existing-session write preflight, source-handle acquisition, schema/bootstrap/migration, and board-presence projection retain their existing owners. Reads capture their physical store before waiting and join the same per-agent FIFO as writes. The read worker retains its admitted read-only connection and reads one coherent snapshot without creating missing board tables or opening a writer publication. The host checks the captured physical identity, native mutation witness, and current authority before starting consumption and releasing the queue. External consumer promises run without holding that queue, so queued revocation cannot be overtaken by a later protected publication. Gateway close rejects new requests and joins accepted reads and publication cleanup before worker teardown. Incognito reads and writes continue on their process-held connection. The worker never owns a second agent database actor, and this cut changes no board schemas, retention, update behavior, or protocol payloads.

Board existence = any rows for the sessionKey. Deleting a session deletes its board rows. /new//reset does not touch them.

Protocol surface

RPCs (core method table, typebox schemas in gateway-protocol):

Events (in EVENT_SCOPE_GUARDS, read scope):

Board widget bytes use the authenticated HTTP surface. Inline Canvas widget bytes use canvas.document.view. Native clients and direct document opens keep the existing Canvas HTTP routes.

Agent tools

Three tools total, in core. show_widget is exposed for an inline-widgets client, one unambiguous matching current-channel presenter, or a persistent-session automation whose server-authored tool policy explicitly allows pinned-only authoring:

Tool descriptions teach the size/anchor vocabulary and the tier model. The agent is told about user tier-1 events via session notices, e.g. [dashboard] user clicked "Refresh" on widget weather (tab main).

What this replaces

Current boundaries