Session Dashboard Architecture and Implementation Plan
Technical design for session dashboards, covering how boards become the session's primary face with live widgets, and the guiding principles for implementation. For engineers building or extending the dashboard feature.
Read this when
- Maintaining or reviewing session dashboards and their security boundaries
- Changing widget hosting, the widget bridge, or board storage
Note
This technical design document covers the session dashboard feature and was authored both before and during its development. It serves as the definitive reference for the implementation. Once the feature launches,
/web/dashboardbecomes the page users see, while this document remains the architectural reference.
Vision
Today, interacting with an agent means reading a text stream. The dashboard turns that into a workspace: the agent displays live, interactive widgets; the user pins them onto a persistent surface; chat sits to the side (or is hidden) and the board becomes the primary content. Instead of "chatting with the agent," you're "operating a control panel the agent built," all within the same session.
Guiding principles:
- A board is a session's face, not a separate entity. Every session (thread)
has two faces: the transcript and the board. A session without pinned widgets
is just chat. Pin one widget and a board comes into existence. Boards share
the session's identity, agent ownership, naming, pinning, and lifecycle. There
is no
dashboard_create, no board registry, and no separate ACL model. - Agent parity. Any board action available to the user is also available to the agent through tools: adding/updating/removing widgets, arranging them, managing tabs, switching the visible tab, and docking or hiding chat.
- Native, not embedded. The board consists of Lit components in the Control UI shell, using the same design system as the rest of the app. Only widget content runs inside sandboxed iframes. No URL bar, no browser chrome.
- Small agent surface. Widgets are referenced by stable name and updated in place. Layout uses a fluid auto-compacting grid; the agent specifies sizes and anchors, never pixels or coordinates.
- Capabilities over trust. Widget code is arbitrary agent-authored HTML/JS running in a hard sandbox. Reach (gateway data, actions, network) is available only through a declared capability manifest that the operator grants.
Concepts
| Concept | Definition |
|---|---|
| Session (thread) | Existing gateway session, keyed by stable sessionKey. Owned by an agent. |
| Board | The widget face 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, their arrangement, and the chat dock state (left/right/bottom/hidden). Boards start with one implicit tab. |
| Widget | Named, sandboxed HTML/JS program owned by the session. Addressed as sessionKey + name. Updated in place by 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) | Existing sidebar pinning of sessions. A pinned session with a board opens on its board face. |
UX flows
- Graduation: when the agent calls
show_widgetin any chat, the widget renders inline in the transcript exactly as it does today. Hovering reveals Pin to dashboard, which places the widget on the session's board. The agent can achieve the same result by passingpin: true. - Board view: a session that has a board gets a face toggle (Chat / Dashboard). The board view consists of a tab strip (only when there's more than one tab), a fluid grid, and a docked chat pane. The chat dock can be resized, moved (left/right/bottom), and collapsed, just like the sidebar. Each tab remembers its own dock state.
- Drag: users can drag widgets, and the grid auto-compacts (widgets float up, neighbors reflow). Resizing via the handle snaps to size steps. No pixel placement, for anyone.
- Reset warning:
/new//reseton a session that has a board prompts for confirmation in the web UI ("context resets, the dashboard stays") and preserves the board. - Sidebar: pinned sessions display their board face when they have one. The Home session's board serves as the default "agent dashboard".
- Interactions (three tiers, described below): silent state events, visible prompt sends, and automation triggers.
Interaction tiers
- 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. - Prompts (explicit talk).
bridge.sendPrompt(text), requires user activation; sends a visible user message into the session (the docked chat shows it). Rate-limited; each send is user-confirmed unless the widget holds thepromptcapability grant. - Automation.
bridge.runAction(name, args), fires a manifest-declared action. Initial verb set:cron.trigger(run an existing cron job now) andbinding.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
Widget HTML/JS is authored by the agent (typically via show_widget), wrapped
in the standard document shell (CSP meta, size reporter, bridge bootstrap) and
rendered in <iframe sandbox="allow-scripts"> (never allow-same-origin).
- Inline (transcript) widgets keep the current canvas-document pipeline: written under the state dir, served by the gateway, pruned per scope, no approval (they are capless by construction, prompt sends are user-confirmed).
- Board widgets are session state: bytes live in the owning agent's SQLite
DB (
board_widgets), served by a core gateway route (/__openclaw__/board/<agentId>/<sessionKey>/<name>/) that reads the DB. Pinning a transcript widget copies the bytes. Caps: 256 KB per widget, 48 widgets per board. - Update in place: re-emitting a widget with the same
namereplaces the bytes, bumpsrevision, broadcastsboard.changed, and live views reload that iframe only. - Byte freezing: granted capabilities bind to the sha256 of the widget
bytes. Changing bytes keeps
data/net/actionsgrants only if the new revision declares a subset of the granted manifest; a widened manifest re-prompts the operator.
Widgets host content; MCP apps are one content kind
The widget is the OpenClaw primitive: the named, pinned, sized, session-owned board cell with a grant record. What renders inside it is a content kind:
html, agent-authored viashow_widget, bytes in board storage.mcp-app, a third-party MCP app view (ui://resource from a configured server) hosted inside the widget cell.
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
OpenClaw's, so show_widget code stays as short as it is today and never
needs to know the MCP Apps spec exists.
Shared infrastructure underneath (this is where the simplification lands):
- A single sandbox host.
htmlwidgets go through the same hardened pipeline that MCP apps already use (double-iframe on the dedicated sandbox origin, per-widget CSP declared and fail-closed decoded) rather than a separate custom iframe host. Because the proxy receives HTML by value, locally sourced content fits naturally. - A single authorization model. A widget's scope is a granted allowlist,
regardless of type: for
htmlwidgets, host tools; formcp-appwidgets, the server's app-visible tools (through the existingallowedAppToolNamesmechanism, now persisted per widget instead of per-minting-run). - Host tools for
htmlwidgets (surfaced over the widget bridge, validated against the grant):openclaw.prompt.send, tier 2; goes through the visible composer, user-confirmed unless grantedopenclaw.state.emit, tier 1 session notices (coalesced, size-capped)openclaw.data.read, parameterized read-only bindings (existing allowlisted read RPC set), resolved gateway-sideopenclaw.cron.trigger, tier 3 automation
net= CSP. Network reach relies on the per-widget CSP declaration already shipped (connect-srcorigins), the self-updating weather widget fetches its API straight from the sandbox, with no gateway in the path.- Grants. A widget that declares nothing renders right away (sandboxed,
default-src 'none', prompt sends individually confirmed), the same trust level as today's inline chat widgets. Declared tools or origins place the widget inpendingon the board: a placeholder card lists them in human-readable form with one-tap Allow/Reject. Grants are keyed per widget name; forhtmlwidgets they are byte-frozen (sha256), and changed bytes preserve the grant only when the declaration shrank. - Authoring shim. The document wrapper injects
window.openclaw.prompt,window.openclaw.state,window.openclaw.data, andwindow.openclaw.cronas the stable author API. Dashboard calls share one view-ticket-bound request channel; size reporting and theme tokens stay separate host notifications.
Plugin capability declarations
Enabled plugins can extend the widget host via dashboard.dataBindings
and dashboard.actionVerbs in openclaw.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, OpenClaw 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 cause the plugin load to fail. The validated
registry is rebuilt only on plugin lifecycle changes, while widget grants
remain per-widget and byte-and-revision-bound.
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 in current Chromium. The same residual already ships for
inline chat widgets and the MCP Apps host on main.
Accepted tradeoff: OpenClaw does not gate scriptable widgets on this
residual. Widget content gains access to sensitive OpenClaw data only through
an operator-granted, byte-frozen data:read capability, and the sandbox
Permissions Policy blocks camera and microphone access. A DOM API guard is
best-effort defense-in-depth, not a security boundary, and belongs in
follow-up hardening.
Transcript display: one widget card
Inline display unifies on the widget primitive. When a tool result carries UI, show_widget output or an MCP tool result with an app resource, the system
materializes an ephemeral, auto-named widget (session-scoped, pruned) and
the transcript renders a single widget card that dispatches on content kind.
MCP app auto-display stays exactly as the spec expects (zero extra model work);
it just is a widget underneath. This deletes the parallel mcpApp
special-cases in chat rendering (surface gating, separate dedup), gives every
inline UI the same pin affordance, and makes the widget registry the primary
re-open path (transcript-scan reconstruction stays as fallback for never-pinned
history). The read-only ticketed standalone host overlaps with boards as a
persistent re-open surface, consolidation candidate to evaluate in T6, not
assumed.
Composition: v1 is grid adjacency (agent chrome widget next to an app widget on one tab). v2 adds host-managed app slots, agent widget HTML declares a slot region and the host composites the real app view as a sibling sandbox. The app never renders inside the agent's iframe: nesting would break bridge identity and enable overlay/clickjack of granted app UI, so the slot is a layout contract, not an embed.
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 get 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 stay read-only, still useful for display
dashboards. v1 pins to the originating session's board; cross-session pinning
needs a lease broker and waits. Coordinate with open PR #109807 (ui/message
composer routing, theme/size propagation).
WorkBoard integration
The WorkBoard integration program keeps cards and boards plugin-owned while stitching dispatched cards back to their session boards through the existing sessionKey and runId, exposing WorkBoard feeds and dispatch through plugin-declared bindings and actions, and composing 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:
size:sm(3×3) ·md(6×4) ·lg(8×6) ·xl(12×8) ·full(single-widget tab)after: <widgetName>optional ordering anchor; omitted = append- User drags/resizes freely; the same order+size model round-trips.
Data model (per-agent DB)
New tables in agents/<agentId>/agent/openclaw-agent.sqlite
(requires an agent-DB schema-version bump, operator sign-off is mandatory
before this ships):
CREATE TABLE board_tabs (
session_key TEXT NOT NULL,
tab_id TEXT NOT NULL, -- slug
title TEXT NOT NULL,
position INTEGER NOT NULL,
chat_dock TEXT NOT NULL DEFAULT 'right', -- left|right|bottom|hidden
created_by TEXT NOT NULL, -- 'user' | 'agent'
PRIMARY KEY (session_key, tab_id)
) STRICT;
CREATE TABLE board_widgets (
session_key TEXT NOT NULL,
name TEXT NOT NULL, -- stable widget name
tab_id TEXT NOT NULL,
title TEXT,
html BLOB NOT NULL, -- wrapped document source
sha256 TEXT NOT NULL,
revision INTEGER NOT NULL,
size_w INTEGER NOT NULL,
size_h INTEGER NOT NULL,
position INTEGER NOT NULL, -- order within tab (auto-compact input)
manifest TEXT NOT NULL DEFAULT '{}', -- capability manifest JSON
grant_state TEXT NOT NULL DEFAULT 'none', -- none|pending|granted|rejected
granted_sha TEXT, -- byte-frozen grant
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (session_key, name)
) STRICT;
A board exists whenever any rows are present for the sessionKey. Removing a session also removes its board rows. Neither /new nor /reset affects those rows.
Protocol surface
RPCs (core method table, typebox schemas in gateway-protocol):
board.get { sessionKey }→ tabs plus widget metadata, no bytes, viaoperator.readboard.update { sessionKey, ops[] }, tab CRUD and reorder, widget move/resize/ remove/unpin, dock state, focus-tab, viaoperator.writeboard.widget.put { sessionKey, name, html, manifest, placement },operator.write(agent tool path and pin path)board.widget.grant { sessionKey, name, decision },operator.approvalsboard.event { ticket, payload }, ticket-bound tier-1 state event ingest; the legacy trusted-host{ sessionKey, widget, payload }shape is still supported,operator.writeboard.prompt.authorize { ticket }, tells whether a visible prompt send still needs per-click confirmation,operator.readboard.data.read { ticket, bindingId, params? }, gateway-side allowlisted core or active-plugin read binding resolution,operator.readboard.action { ticket, action, ... }, exact-grant automation dispatch via the existing cron run-now path or an active plugin's validated action verb,operator.write
Events (in EVENT_SCOPE_GUARDS, read scope):
board.changed { sessionKey, revision, widget? }, persisted state changed; UI refetches and reloads one iframe whenwidgetis present.board.command { sessionKey, command }, transient UI drive (agent switches the visible tab, toggles chat dock), theui.commandpattern.
Widget bytes go out over the authenticated HTTP surface, not the socket.
Agent tools
Three tools total (core, always registered; rendering gated on the
inline-widgets client cap as today):
show_widget { title, widget_code, name?, pin?, size?, tab?, after?, capabilities? }, create/update by name;pinputs it on the board. Withoutname/pinit acts exactly like today (inline, ephemeral).dashboard { action, ... }, board management verbs:read,tab_create,tab_update,tab_delete,tabs_reorder,widget_move,widget_remove,unpin,focus_tab,set_chat_dock.- The existing
automationstool handles the automation tier; no new tool is needed.
Tool descriptions teach the size/anchor vocabulary and the tier model. The
agent learns about user tier-1 events through session notices, e.g.
[dashboard] user clicked "Refresh" on widget weather (tab main).
What this replaces
extensions/workspacesis removed. Experimental,enabledByDefault: false, never in a stable release (first appeared in 2026.7.2 betas). No migration; a doctor rule clears stale<stateDir>/workspaces/if found. Harvested ideas: pure grid math, bridge security model (port bootstrap, binding gating, rate limits), byte-frozen approval.- Widget hosting shifts from
extensions/canvasto core. The canvas doc store, document wrapper, HTTP serving, and theshow_widgettool become core (src/canvas/); the plugin keeps the node-canvas control tool (canvas) and A2UI. ThepluginSurfaceUrls["canvas"]advertisement and/__openclaw__/canvaspaths are shipped native-client contracts and remain stable. Discord sessions keep the Discord-ownedshow_widgetvariant.
Non-goals (this program)
- Multi-user board sharing and ACLs are planned for later, delivered through session sharing.
- Native rendering of boards on macOS and iOS is available wherever the Control UI is embedded; the inline widget path stays as is.
- Built-in data widgets, such as sessions, usage, and cron cards, are covered in v1 by the capability bridge plus agent-authored widgets; a registry for builtin kinds can be introduced subsequently.
Implementation plan
Worktrees operate independently, built by Codex, with review and landing done sequentially. Land first, then fix.
| # | Branch | Scope | Depends on |
|---|---|---|---|
| T1 | claude/dashboard-remove-workspaces | Remove the workspaces plugin, its UI, docs, and i18n keys; add a doctor cleanup rule | , |
| T2 | claude/dashboard-canvas-core | Move widget hosting and show_widget into core; the canvas plugin retains the node tool; no behavioral changes | , |
| T3 | claude/dashboard-domain | Agent-DB tables with a schema bump, board.* RPCs and events, the dashboard tool, show_widget arguments for pin, name, and manifest, tier-1 notices, and reset that preserves the board | T2 |
| T4 | claude/dashboard-ui | Board face, tab strip, fluid auto-compact grid, chat dock with left, right, bottom, or hidden placement, transcript pin affordance, sidebar board face, and reset confirmation | T3 (mock-first via dev fixtures) |
| T5 | claude/dashboard-capabilities | Grant store and UI with byte freezing; relocate html widgets to the shared sandbox host; host tools (openclaw.prompt.send/state.emit/data.read/cron.trigger); net CSP; authoring shim | T3, T4 |
| T7 | claude/dashboard-mcp-apps | mcp-app content kind: pin affordance on inline app views, descriptor storage, lease re-mint and refresh, durable server-tool grants (reusing the shipped MCP Apps host) | T3, T4 |
| T6 | polish | Live E2E on a scratch gateway with real keys, screenshots, fixes, a user-focused /web/dashboard rewrite, and an enable-by-default review | all |
Validation follows repo rules: focused vitest locally, full gates on Crabbox and Testbox, $autoreview before each land, and live proof for T6.