Response UI Widgets

Optional, typed affordances on a chat response

A chat response can carry an optional ui array of widgets - small, typed affordances a client can render natively: a set of choices to pick from, a link to send the user somewhere, or a UI resource relayed from an MCP server.

The runtime never renders anything. Clients that understand a widget type render it; clients that don't ignore it. The response text always carries the fallback, so the interaction works even with no widget support at all. Treat unknown widget types as a no-op (progressive enhancement) - never as an error.

Widgets are produced only by trusted runtime producers (formation config, tool results, clarification state, MCP servers), never from free-form LLM output. This is what makes an action_link URL safe to render: it can only reach a widget through a producer that names its source.

How widgets arrive

For non-streaming chat, widgets appear in the response message's optional ui array. For streaming chat, they ride a dedicated event: ui SSE frame emitted at end-of-turn, just before event: done:

data: {"token": "Which region should I use?"}

event: ui
data: {"ui": [{"type": "options", "id": "ui_a1b2c3", "prompt": "Which region?", "options": [{"value": "us", "label": "United States"}, {"value": "eu", "label": "Europe"}], "multi": false}]}

event: done
data: {"finished": true}

A turn with no widgets is byte-identical to a stream that never had this feature - the event: ui frame is simply absent.

In a non-streaming response with no widgets, the message omits ui rather than serializing it as null or an empty array.

Widget types

options

A short question with choices to pick from instead of typing.

{
  "type": "options",
  "id": "ui_a1b2c3d4e5f6g7h8i9j0k",
  "prompt": "Which region should I deploy to?",
  "options": [
    { "value": "us", "label": "United States" },
    { "value": "eu", "label": "Europe" }
  ],
  "multi": false
}
Field Type Description
type string Always "options"
id string Runtime-assigned widget id (ui_ + nanoid); use it on the reply path
prompt string Short question the options answer
options array {value, label} items (up to 25); label falls back to value
multi bool Whether multiple options may be selected (currently always false)

action_link

Send the user somewhere external - a credential portal, OAuth consent screen, or dashboard.

{
  "type": "action_link",
  "id": "ui_k0j9i8h7g6f5e4d3c2b1a",
  "label": "Connect GitHub",
  "url": "https://github.com/login/oauth/authorize?...",
  "hint": "Opens GitHub to authorize access"
}
Field Type Description
type string Always "action_link"
id string Runtime-assigned widget id
label string Button/link text
url string Destination (only http/https URLs are ever emitted)
hint string Optional secondary text

mcp_resource

A gateway passthrough of an MCP App / UI resource returned by an external MCP server. MUXI relays the embedded ui:// resource verbatim - no rendering, no interpretation, no execution. It is untrusted external content; the producing server and tool are recorded on the widget as provenance.

{
  "type": "mcp_resource",
  "id": "ui_z9y8x7w6v5u4t3s2r1q0p",
  "resource": "ui://weather-card",
  "data": "<!doctype html>...",
  "server": "weather-mcp",
  "tool": "get_forecast",
  "mime_type": "text/html",
  "encoding": "base64"
}
Field Type Description
type string Always "mcp_resource"
id string Runtime-assigned widget id
resource string The resource URI (only the ui:// scheme is accepted)
data string Embedded content, verbatim (text, or base64 blob)
server string MCP server id that returned the resource (provenance)
tool string Tool name whose result carried the resource (provenance)
mime_type string Declared mimeType, when present
encoding string "text" (default, omitted on the wire) or "base64"

Producing widgets

Widgets can only be created by trusted producers - never from free-form LLM output. This is what makes an action_link URL safe to render.

  • options - produced automatically by clarifications that offer enumerable choices (e.g. credential account selection).
  • action_link - a URL can only enter a widget through a producer that names its source:

  • A top-level links: formation section maps a name to a link, surfaced on credential-redirect responses: yaml # formation.afs links: github: label: "Connect GitHub" url: "https://github.com/login/oauth/authorize?..." hint: "Opens GitHub to authorize access"

    Use the credential service name as the key, or credential_portal as a generic fallback.

  • A tool result may carry a _link key (mirroring the _artifact convention) to surface an action link produced by a tool.

  • mcp_resource - relayed verbatim when an external MCP tool result carries an embedded ui:// resource block. No configuration; provenance (server + tool) is recorded on the widget.

Replying to a widget

An options widget that came from a clarification can be answered on the next turn. Send the selected value back as a ui_response hint alongside the message text (the text is always sufficient on its own; the hint just pins the choice deterministically):

{
  "message": "United States",
  "ui_response": { "id": "ui_a1b2c3d4e5f6g7h8i9j0k", "value": "us" }
}

The runtime resolves the hint only when id matches the widget that asked the question and value is one of the offered options. Unknown or stale ids are ignored and the message stands alone. The runtime stays stateless - there is no server-side widget store.

Channel-native rendering

The bundled Telegram, Slack, and Discord transformers render options and action_link widgets as native buttons while preserving the complete text fallback. Email remains text-only. Formation-local transformer files are unchanged unless they explicitly use the channel UI namespace:

Channel Template value
Telegram ${{ ui.telegram.reply_markup }}
Slack ${{ ui.slack.blocks }}
Discord ${{ ui.discord.components }}

Option callbacks encode #, keeping Telegram callback data within its 64-byte limit and avoiding server-side widget storage. Configure the inbound trigger's parse.ui_response path to round-trip button presses:

# Telegram
parse:
  ui_response: "$.callback_query.data"

# Slack
parse:
  ui_response: "$.actions[0].value"

# Discord
parse:
  ui_response: "$.data.custom_id"

Foreign callback values decode to no hint, so the accompanying message still stands alone. Discord component buttons require a bot/application bridge; plain Discord webhooks cannot send components.

SDK helpers

The SDKs surface widgets idiomatically:

  • Go decodes the frame into ChatChunk.UI ([]UIWidget with typed UIOptions).
  • TypeScript yields the frame as a chunk of { type: "ui", ui: UIWidget[] } (UIWidget/UIOption interfaces exported).
  • Python, Ruby, PHP, C#, Java, Kotlin, Swift, Dart, Rust, C++ expose a parse_ui_widgets / parseUiWidgets helper that extracts the widgets array from a ui stream frame and returns empty for any other or malformed frame.

See the SDK reference for per-language signatures.

Size limits

Widgets are clamped so a misbehaving producer cannot bloat responses. Oversized widgets are dropped whole (never truncated - the text fallback is always complete):

Limit Default
Widgets per envelope 8
Bytes per standard widget 4 KB
Combined standard-widget bytes 16 KB
Options per options widget 25
Bytes per mcp_resource widget 64 KB
Combined mcp_resource bytes 128 KB

Learn More


Home Docs SDKs
Star on GitHub