Display HTML Widgets with show_widget Tool

Learn how to use show_widget to display self-contained HTML widgets across chat surfaces. This guide covers widget behavior in Control UI, Quick Chat, and Discord Activities.

Read this when

  • You want an agent to render an interactive result in web chat, a native app, or Discord
  • You want widget buttons to send follow-up prompts into the chat
  • You want to theme widgets with the shared design tokens
  • You need the show_widget input, security, or retention contract

show_widget serves as a fundamental tool for displaying a self-contained HTML widget on the user's current surface. OpenClaw renders this widget inline within the Control UI and across Quick Chat transcripts on iOS, Android, macOS, and Linux; the Linux dashboard relies on the browser-based Control UI. When used in a Discord session with Activities turned on, the Discord plugin provides an Open widget button that starts the widget as an Activity.

How widgets work

When the agent invokes show_widget, the OpenClaw core wraps widget_code inside a minimal HTML document, saves it as a Canvas document, and provides a preview handle. The Control UI displays this handle within a sandboxed iframe, while iOS, Android, macOS, and Linux Quick Chat employ isolated web views. Full chat clients restore the widget after reloading history; Quick Chat preserves the widget for its active reply.

In Control UI sessions, a Canvas widget can also be fixed to the session dashboard. Set pin: true in the tool call, or choose Pin to dashboard from an existing transcript widget. Pinned HTML operates behind the same dedicated-origin, double-iframe sandbox host used by MCP Apps; the browser never resolves a widget data binding inside the untrusted frame.

For embedding in a browser, the wrapper document inserts four small host bridges around the widget code:

  • A size reporter sends the rendered content height to the embedding chat, which limits it and adjusts the iframe (between 160 and 1200 pixels).
  • A host bridge defines the legacy sendPrompt(text) helper along with the structured openclaw.prompt, openclaw.state, openclaw.data, and openclaw.cron APIs. Inline chat prompts keep their private message channel; dashboard APIs use a view-ticket-bound request channel. Refer to Interactive widgets and Dashboard capabilities.
  • A theme bridge listens for the Control UI's current design tokens and applies them as CSS variables, both on load and after every theme change.
  • A snapshot bridge renders the current widget document as a PNG when the embedding chat requests an export.

Everything else remains inside the frame: the document runs in an opaque origin with a strict Content Security Policy, so widget scripts cannot access the Control UI, the Gateway, or the network.

The core implementation is only available when the originating Gateway client declares the inline-widgets capability. The Control UI and supported native apps declare this capability automatically. Linux Quick Chat remains text-only for Gateway connections that require a custom TLS leaf pin because its platform WebView cannot bind that pin. The Discord implementation is only available in Discord sessions with Activities configured. Other channel runs do not receive show_widget.

Capability transport covers embedded, Codex app-server, and CLI-backed model backends. Grant-authenticated MCP callers and direct HTTP tool-invoke callers remain fail closed because they do not declare client capabilities.

Design system

Every Canvas widget includes a classless base stylesheet and a small token set:

TokenPurpose
--surfacePage-level surface color
--cardCard, button, and code background
--elevatedElevated form-control background
--textDefault body and control text
--text-strongHeadings and prominent values
--mutedSecondary text and subtle borders
--borderStandard separators and card borders
--border-strongStrong control borders
--accentLinks and focus rings
--accent-fillPrimary action fill
--accent-fgText on a primary action
--okSuccess state
--warnWarning state
--dangerError or destructive state
--infoInformational state
--radiusShared control and card corner radius
--font-bodyHost body font stack
--font-monoHost monospace font stack
--accent-subtle, --ok-subtle, --warn-subtle, --danger-subtle, --info-subtleDerived translucent state backgrounds

Bare headings, paragraphs, links, buttons, inputs, selects, textareas, tables, and code blocks receive base styles. Helper classes provide common patterns:

  • .card for a bordered content surface
  • .badge, plus .ok, .warn, .danger, or .info, for compact status labels
  • .metric for a prominent numeric value
  • .muted for secondary text
  • .row for a wrapping horizontal layout
  • button.primary for the primary action

The Control UI posts an openclaw:widget-theme message with the active theme values when a widget loads and whenever the theme changes. Widgets therefore track every theme family, including Claw, Knot, Dash, and custom themes, without reloading. Outside the Control UI, including native apps and direct opens, widgets use the baked light or dark palette selected by prefers-color-scheme.

Author widgets with three rules:

  1. Use the design variables for every color and background. Do not hardcode color values.
  2. Keep the page background transparent so the widget belongs to its host surface.
  3. Reserve --accent-fill for at most one primary action.

Export: In web chat, open the widget card menu to copy the rendered widget to the clipboard or download it as a PNG. Older widget documents without the snapshot bridge fall back to an HTML file download.

Use the tool

Both implementations use the same required fields:

  • title (string, required), Short title shown with the inline preview and in the hosted document title.

  • widget_code (string, required), Self-contained HTML or SVG. For inline-widget clients, input beginning with <svg after trimming is rendered in SVG mode; maximum length is 262,144 characters. Discord accepts a complete HTML document or body fragment up to 48 KiB.

Discord also accepts optional button_label text for the Activity launch button. The Canvas schema intentionally omits this Discord-only field.

The core Canvas tool accepts these optional dashboard placement fields:

  • pin: additionally, place the widget on the session dashboard.
  • name: a fixed widget name; by default, it is a slug derived from title.
  • tab: the slug for the destination tab.
  • size: any one of sm, md, lg, xl, or full.
  • after: the name of a sibling widget that this one should follow.
  • capabilities: access needed by a pinned widget. netOrigins lists precise HTTPS origins; tools holds prompt, an allowlisted read binding, or an exact cron.trigger:<jobId> action.

A Canvas preview handle is part of the core result, letting the Control UI and supported native apps render the widget directly from the tool call and restore it after a history reload. Pinned results also keep the board widget name so the Control UI does not offer a duplicate pin after a transcript reload. Discord returns both the stored widget and the identifiers for posted messages.

discord_widget stays registered as a deprecated alias for one release. New agent calls should switch to show_widget.

Interactive widgets

Widget scripts can drive the conversation inside the Control UI. The wrapper document defines a global sendPrompt(text) function; invoking it sends text to the chat just as if the user typed and sent the message. Connect it to buttons or other controls to build interactive flows like pickers, quizzes, or drill-down dashboards. Native apps render interactive widget code but do not expose this chat prompt bridge.

<button onclick="sendPrompt('Show the failing tests in detail')">Failing tests</button>

Every prompt is validated on both sides of the frame boundary:

  • sendPrompt requires transient user activation inside the widget: it only works for a few seconds after the user clicks or presses a key in the widget, so attach it to buttons and other click targets. Calling it automatically on load does nothing. The bridge keeps the sending endpoint private and fails closed in browsers that do not expose user activation, so widget code cannot bypass the check.
  • Only the original widget document has prompt authority. The trusted bridge offers its channel endpoint to the chat before widget code can run or navigate the frame, the chat accepts only that first offer, and the channel dies with the document on navigation. Externally allowed embed URLs are never adopted.
  • The widget frame must be visible in the chat transcript and hold focus, an additional host-observed signal that the user is actually interacting with this widget.
  • The text must be non-empty after trimming and no more than 4,000 characters.
  • Prompts starting with / are rejected, so widget code cannot trigger chat commands like /approve or /stop.
  • Each widget document may send at most 10 prompts per rolling minute; excess prompts are silently dropped.

Accepted prompts appear in the transcript as regular user messages and start a normal agent turn in the session that owns the widget. There is no feedback channel into the widget: a dropped prompt fails silently, and the widget cannot read the agent's reply.

Dashboard capabilities

Pinned widgets can use one ticket-bound host API after the operator reviews the declaration shown on the pending card:

  • openclaw.prompt.send(text) requires transient user activation and posts a visible composer message. Declaring and receiving the prompt tool grant skips the extra per-click confirmation; validation, focus checks, and rate limits still apply.
  • openclaw.state.emit(payload) adds a session notice. Payloads are capped at 8 KiB, and identical client emissions within five seconds are coalesced.
  • openclaw.data.read(bindingId, params?) resolves only at the Gateway. Grantable bindings are sessions.list, usage.status, usage.cost, cron.list, cron.status, agents.list, and health.
  • openclaw.cron.trigger(jobId) runs an existing job now only when the exact cron.trigger:<jobId> capability was granted.

Network access is separate from host tools. Put exact HTTPS origins in capabilities.netOrigins; after approval, only those origins enter the widget's connect-src. Wildcards, credentials, paths, query strings, and undeclared origins remain blocked. A literal port is allowed only when it is part of the declared origin.

Security and storage

Widget documents use restrictive Content Security Policies. Inline style and script are allowed, while external resource loads remain blocked. Inline transcript widgets cannot fetch the network. A pinned dashboard widget can fetch only exact HTTPS origins that the agent declared and the operator granted.

The Control UI iframe always omits allow-same-origin, even when the global embed mode is trusted, so widget scripts cannot read the parent application origin. Native clients use isolated, nonpersistent web views and block navigation away from the hosted widget. The core document host also serves widgets with a Content-Security-Policy: sandbox allow-scripts response header, so direct rendering still runs the widget in an opaque origin instead of an application origin. Only render widget code you are willing to execute in that isolated frame.

The iframe also follows gateway.controlUi.embedSandbox. The default scripts tier supports interactive widgets while preserving origin isolation.

The accepted WebRTC data-channel egress residual is documented in Dashboard Architecture.

Canvas retains at most 32 widgets per session (or per agent when no session is available). Creating another widget removes the oldest document in that scope.