Message Presentation: Portable Rich Chat UI for Plugins
Learn how OpenClaw's message presentation layer lets agents and plugins define rich outbound chat UI once, with channel plugins rendering native formats. Covers contract, button semantics, and delivery hints.
Read this when
- Adding or modifying message card, chart, table, button, or select rendering
- Building a channel plugin that supports rich outbound messages
- Changing message tool presentation or delivery capabilities
- Debugging provider-specific card/block/component rendering regressions
Message presentation is OpenClaw's unified way of describing rich outbound chat UI. With it, agents, CLI commands, approval flows, and plugins can specify the intent of a message once, and every channel plugin renders the most appropriate native format it can manage.
This presentation layer is meant for portable message UI: text sections, small context or footer text, dividers, charts, tables, buttons, select menus, and card title or tone.
Do not extend the shared message tool with provider-native fields such as Discord components, Slack blocks, Telegram buttons, Teams card, or Feishu card. Those are renderer outputs that belong to the channel plugin.
Contract
Plugin authors import the public contract from:
import type {
MessagePresentation,
ReplyPayloadDelivery,
} from "openclaw/plugin-sdk/interactive-runtime";
Shape:
type MessagePresentation = {
title?: string;
tone?: "neutral" | "info" | "success" | "warning" | "danger";
blocks: MessagePresentationBlock[];
};
type MessagePresentationBlock =
| { type: "text"; text: string }
| { type: "context"; text: string }
| { type: "divider" }
| { type: "buttons"; buttons: MessagePresentationButton[] }
| { type: "select"; placeholder?: string; options: MessagePresentationOption[] }
| {
type: "chart";
chartType: "pie";
title: string;
segments: Array<{ label: string; value: number }>;
}
| {
type: "chart";
chartType: "bar" | "area" | "line";
title: string;
categories: string[];
series: Array<{ name: string; values: number[] }>;
xLabel?: string;
yLabel?: string;
}
| {
type: "table";
caption: string;
headers: string[];
rows: Array<Array<string | number>>;
rowHeaderColumnIndex?: number;
};
type MessagePresentationAction =
| { type: "command"; command: string }
| { type: "callback"; value: string }
| {
type: "approval";
approvalId: string;
approvalKind: "exec" | "plugin";
decision: "allow-once" | "allow-always" | "deny";
}
| {
type: "question";
questionId: string;
optionValue: string;
}
| { type: "url"; url: string }
| {
type: "web-app";
url: string;
widgetId?: string;
}
| {
type: "web-app";
url?: string;
widgetId: string;
};
type MessagePresentationButton = {
label: string;
action?: MessagePresentationAction;
/** Legacy callback value. Prefer action for new controls. */
value?: string;
/** @deprecated Use an action with type "url". */
url?: string;
/** @deprecated Use an action with type "web-app". */
webApp?: { url: string };
/** @deprecated Use an action with type "web-app". */
web_app?: { url: string };
priority?: number;
disabled?: boolean;
reusable?: boolean;
style?: "primary" | "secondary" | "success" | "danger";
};
type MessagePresentationOption = {
label: string;
action?: Extract<MessagePresentationAction, { type: "command" | "callback" }>;
/** Legacy callback value. Prefer action for new controls. */
value?: string;
};
type ReplyPayloadDelivery = {
pin?:
| boolean
| {
enabled: boolean;
notify?: boolean;
required?: boolean;
};
};
Button semantics:
action.type: "command"executes a native slash command through core's command path. Use it for built-in command buttons and menus.action.type: "callback"transports opaque plugin data through the channel's interaction path. Channel plugins must not treat callback data as slash commands.action.type: "approval"points to one durable operator approval, its explicitexecorpluginkind, and the requested decision. Channel plugins encode that action into a transport-private callback and resolve it via the approval service; they must not parse/approvecommand text or deduce kind from the ID.action.type: "question"points to one choice for a live, runtime-authoredask_userquestion. Likeapproval, this is an OpenClaw runtime action; agents and plugins must not generate question IDs. Telegram, Discord, and Slack map it to transport-private native callbacks and resolve the choice through the Gateway. Once the question is answered, expired, or cancelled, those channels edit the delivered message, remove its actions, and append the terminal status. WhatsApp, Signal, and iMessage render up to four single-select choices as1️⃣through4️⃣reactions. Other question shapes degrade to label text, and the user can answer with a plain-text reply.action.type: "url"opens a standard link.action.type: "web-app"launches a channel-native web app. Seturlfor a URL-backed app orwidgetIdfor an OpenClaw-hosted widget whose launch mechanics are owned by the channel; at least one is required. When both are present, a channel can prefer its native hosted-widget launch and use the URL where that mechanism is unavailable.valueis the legacy opaque callback value. New controls should useactionso channel plugins can map commands and callbacks without guessing from text.url,webApp, andweb_appremain accepted as deprecated boundary inputs. Normalizers preserve these fields so renderers can distinguish shipped legacy semantics from explicit typed actions. New producers should useaction.labelis required and is also used in text fallback.styleis advisory. Renderers should map unsupported styles to a safe default, not fail the send.priorityis optional. When a channel advertises action limits and controls must be dropped, core keeps higher-priority buttons first and preserves original order among equal priority buttons. When all controls fit, authored order is preserved.disabledis optional. Channels must opt in withsupportsDisabled; otherwise core degrades the disabled control to non-interactive fallback text. A disabled button always renders label-only in fallback text, even when it carries acommandaction.reusableis optional. Channels that support reusable native callbacks may keep the action available after a successful interaction. Use it for repeatable or idempotent actions such as refresh, inspect, or more details; leave it unset for normal one-shot approvals and destructive actions.
Select semantics:
options[].actionaccepts onlycommandorcallback; approval and link actions are button-only.options[].valueis the legacy selected application value.placeholderis advisory and may be ignored by channels without native select support.- If a channel does not support selects, fallback text lists the labels.
Chart semantics:
pierequires positive segment values.bar,area, andlineuse one orderedcategoriesarray. Every series supplies exactly one finite value per category, in the same order.- Category labels and series names must be unique. Invalid or incomplete chart blocks are dropped during normalization rather than silently changing data.
- Native chart rendering is opt-in through
presentationCapabilities.charts. Other channels receive the chart title, axes, categories, series, and values as deterministic text. This is also the accessibility fallback.
Table semantics:
-
captionis a required short heading.headersmust contain at least one unique, non-empty column label. -
rowsmust contain at least one row. Every row must have exactly one cell per header, and every cell must be a non-empty string or a finite number. -
rowHeaderColumnIndexis an optional zero-based index identifying the column whose cells should be exposed as row headers by native renderers. -
Table normalization is atomic. An invalid caption, header, row width, cell, or row-header index drops the table block instead of truncating or repairing its data.
-
Native table rendering is opt-in through
presentationCapabilities.tables. Other channels receive the caption and every row as deterministic linear text, with internal whitespace collapsed:Open pipeline (table) - Account: Acme; Stage: Won; ARR: 125000 - Account: Globex; Stage: Review; ARR: 82000
There is no separate report discriminator. Compose a report from title, tone, text, context, chart, table, and action blocks. This keeps each block independently renderable and gives the complete report the same deterministic text fallback.
Producer examples
A basic card looks like this:
{
"title": "Deploy approval",
"tone": "warning",
"blocks": [
{ "type": "text", "text": "Canary is ready to promote." },
{ "type": "context", "text": "Build 1234, staging passed." },
{
"type": "buttons",
"buttons": [
{
"label": "Approve",
"action": { "type": "callback", "value": "deploy:approve" },
"style": "success"
},
{
"label": "Decline",
"action": { "type": "callback", "value": "deploy:decline" },
"style": "danger"
}
]
}
]
}
A link button that only carries a URL:
{
"blocks": [
{ "type": "text", "text": "Release notes are ready." },
{
"type": "buttons",
"buttons": [
{
"label": "Open notes",
"action": { "type": "url", "url": "https://example.com/release" }
}
]
}
]
}
A button that launches a Telegram Mini App:
{
"blocks": [
{
"type": "buttons",
"buttons": [
{
"label": "Launch",
"action": { "type": "web-app", "url": "https://example.com/app" }
}
]
}
]
}
A dropdown selection menu:
{
"title": "Choose environment",
"blocks": [
{
"type": "select",
"placeholder": "Environment",
"options": [
{ "label": "Canary", "value": "env:canary" },
{ "label": "Production", "value": "env:prod" }
]
}
]
}
A chart visualization:
{
"blocks": [
{
"type": "chart",
"chartType": "line",
"title": "Quarterly revenue",
"categories": ["Q1", "Q2", "Q3"],
"series": [
{ "name": "Product", "values": [120, 145, 138] },
{ "name": "Services", "values": [80, 95, 104] }
],
"xLabel": "Quarter",
"yLabel": "Revenue"
}
]
}
A tabular report:
{
"title": "Pipeline report",
"tone": "info",
"blocks": [
{ "type": "text", "text": "Current opportunities by stage." },
{
"type": "table",
"caption": "Open pipeline",
"headers": ["Account", "Stage", "ARR"],
"rows": [
["Acme", "Won", 125000],
["Globex", "Review", 82000]
],
"rowHeaderColumnIndex": 0
},
{ "type": "context", "text": "Updated from the CRM snapshot." }
]
}
Sending via the command line:
openclaw message send --channel slack \
--target channel:C123 \
--message "Deploy approval" \
--presentation '{"title":"Deploy approval","tone":"warning","blocks":[{"type":"text","text":"Canary is ready."},{"type":"buttons","buttons":[{"label":"Approve","value":"deploy:approve","style":"success"},{"label":"Decline","value":"deploy:decline","style":"danger"}]}]}'
Delivering a pinned message:
openclaw message send --channel telegram \
--target -1001234567890 \
--message "Topic opened" \
--pin
Pinning with an explicit JSON payload:
{
"pin": {
"enabled": true,
"notify": true,
"required": false
}
}
Renderer contract
Channel plugins advertise their rendering capabilities on the outbound adapter:
const adapter: ChannelOutboundAdapter = {
deliveryMode: "direct",
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
charts: false,
tables: false,
limits: {
actions: {
maxActions: 25,
maxActionsPerRow: 5,
maxRows: 5,
maxLabelLength: 80,
maxValueBytes: 100,
supportsStyles: true,
supportsDisabled: false,
},
selects: {
maxOptions: 25,
maxLabelLength: 100,
maxValueBytes: 100,
},
text: {
maxLength: 2000,
encoding: "characters",
markdownDialect: "discord-markdown",
},
},
},
deliveryCapabilities: {
pin: true,
},
renderPresentation({ payload, presentation, ctx }) {
return renderNativePayload(payload, presentation, ctx);
},
async pinDeliveredMessage({ target, messageId, pin }) {
await pinNativeMessage(target, messageId, { notify: pin.notify === true });
},
};
When a capability hinges on settings tied to a specific account, or on the text funnel used for delivery, such as Telegram only producing native tables for richMessages accounts and exclusively on the markdown path, the optional resolvePresentationCapabilities({ cfg, accountId, formatting }) hook should be declared next to the static object. Capabilities are resolved once per delivery by core, and the hook overrides the static declaration; the static object remains the baseline that applies regardless of account.
const adapter: ChannelOutboundAdapter = {
presentationCapabilities: BASE_CAPABILITIES,
resolvePresentationCapabilities: ({ cfg, accountId, formatting }) => ({
...BASE_CAPABILITIES,
tables: isRichAccount(cfg, accountId) && formatting?.parseMode !== "HTML",
}),
// ...
};
Capability booleans signal what the renderer can turn into interactive elements. Optional limits describe the generic envelope that core can adapt prior to invoking the renderer:
type ChannelPresentationCapabilities = {
supported?: boolean;
buttons?: boolean;
selects?: boolean;
context?: boolean;
divider?: boolean;
charts?: boolean;
tables?: boolean;
limits?: {
actions?: {
maxActions?: number;
maxActionsPerRow?: number;
maxRows?: number;
maxLabelLength?: number;
maxValueBytes?: number;
supportsStyles?: boolean;
supportsDisabled?: boolean;
supportsLayoutHints?: boolean;
};
selects?: {
maxOptions?: number;
maxLabelLength?: number;
maxValueBytes?: number;
};
text?: {
maxLength?: number;
encoding?: "characters" | "utf8-bytes" | "utf16-units";
markdownDialect?: "plain" | "markdown" | "html" | "slack-mrkdwn" | "discord-markdown";
supportsEdit?: boolean;
};
};
};
Before rendering happens, core enforces generic limits on semantic controls. Final validation and clipping for provider-specific details, such as native block counts, card dimensions, URL constraints, and quirks that the generic contract cannot capture, remain the renderer's responsibility. When limits strip every control from a block, core preserves the labels as non-interactive context text, so the delivered message still offers a visible fallback.
Core render flow
On the standard outbound path used by the CLI and normal message actions, core performs these steps:
- The presentation payload gets normalized.
- The outbound adapter for the target channel is resolved.
presentationCapabilitiesis read.- Generic capability limits, covering action count, label length, and select option count, are applied when the adapter declares them. Chart and table blocks turn into deterministic text unless the adapter explicitly declares
charts: trueortables: true, respectively. renderPresentationis invoked when the adapter can render the payload.- If the adapter is missing or cannot render, a conservative text fallback is used.
- The resulting payload goes through the normal channel delivery path.
- After the first successful send, delivery metadata like
delivery.pinis applied.
Channel-local reply or preview funnels that directly consume ReplyPayload must either route through that canonical path or produce the same presentation fallback before reducing the payload to plain text or media.
Fallback behavior is owned by core, which lets producers stay agnostic about the channel. Native rendering and interaction handling belong to the channel plugins.
Degradation rules
On limited channels, presentation must remain safe to deliver.
Producers who manually author the plain rendering of the same content can tag it with presentationTextMode: "fallback" on the reply payload. Channels that natively render the presentation's data blocks drop that text; when every table and chart block degrades and no interactive block survives, the authored text is sent as-is instead of the generic flatten below.
The fallback text consists of:
titleas the opening linetextblocks rendered as standard paragraphscontextblocks as brief context linesdividerblocks as a visual divider- button labels, with URLs included for link buttons
- select option labels
- chart title, type, axes, categories, series, and values
- table caption, headers, and each row value
Button value fallback visibility
When a channel cannot render interactive controls, button and select values degrade to plain text. This fallback keeps things usable while hiding opaque callback data:
- Actions typed as
commandappear aslabel: `command`, letting users copy the command and execute it manually in the channel input. - Actions typed as
callbackand legacyvaluefields show only the label. The opaque callback value stays out of the fallback text. - Actions typed as
approvalshow only the label. Approval IDs and decisions count as transport data and are not surfaced through generic scalar helpers or fallback text. urlactions, URL-backedweb-appactions, and deprecatedurl/webApp/web_appinputs display the URL text beside the button label, since the URL is meant for users. Hosted-widget-only actions show only the label on channels that lack a native widget launch.- Select options show only the label. The underlying option value is not revealed in fallback text.
Channel adapters that add manual-command guidance to their fallback UI, such as Feishu document-comment instructions, must base the command-present check on the same presentation blocks the fallback renderer uses, so the guidance only appears when a manual command is actually shown.
Unsupported native controls should degrade instead of aborting the entire send. Some examples:
- Telegram with inline buttons disabled falls back to text.
- A channel without select support renders select options as text.
- A channel without native chart support renders chart data as text.
- A channel without native table support renders every table row as text.
- A URL-only button becomes either a native link button or a fallback URL line.
- Optional pin failures do not fail the delivered message.
The one exception is delivery.pin.required: true; when pinning is required and the channel cannot pin the sent message, delivery is reported as failed.
Provider mapping
Renderers currently bundled:
| Channel | Native render target | Notes |
|---|---|---|
| Discord | Components and component containers | Keeps legacy channelData.discord.components for existing provider-native payload producers, but new shared sends should prefer presentation. |
| Feishu | Interactive cards | Card header may use title; the body should avoid repeating that title. |
| Matrix | Text fallback plus structured event field | Buttons and selects are advertised as supported, yet every block currently renders as renderMessagePresentationFallbackText output carried in a com.openclaw.presentation event field, not native interactive widgets. |
| Mattermost | Text plus interactive props | Selects and dividers are unsupported; those blocks degrade to text. |
| Microsoft Teams | Adaptive Cards | Plain message text is included with the card when both are supplied. Selects, styles, and disabled state are unsupported. |
| Slack | Block Kit | Renders chart as native data_visualization and table as native data_table; preserves legacy channelData.slack.blocks, but new shared sends should prefer presentation. |
| Telegram | Text plus inline keyboards | Buttons and selects require inline button capability on the target surface; otherwise text fallback applies. |
| Plain channels | Text fallback | Channels without a renderer still receive readable output. |
Provider-native payload compatibility exists as a transition aid for existing reply producers. It does not justify adding new shared native fields.
Presentation vs InteractiveReply
InteractiveReply is the older internal subset used by approval and interaction helpers. It covers:
- text
- buttons
- selects
MessagePresentation is the canonical shared send contract. It adds:
- title
- tone
- context
- divider
- chart
- table
- URL-only buttons
- generic delivery metadata through
ReplyPayload.delivery
When integrating legacy code, pull in helpers from openclaw/plugin-sdk/interactive-runtime:
import {
adaptMessagePresentationForChannel,
applyPresentationActionLimits,
hasMessagePresentationBlocks,
interactiveReplyToPresentation,
isMessagePresentationInteractiveBlock,
normalizeMessagePresentation,
presentationPageSize,
presentationToInteractiveControlsReply,
presentationToInteractiveReply,
renderMessagePresentationChartFallbackText,
renderMessagePresentationFallbackText,
renderMessagePresentationTableFallbackText,
resolveMessagePresentationActionValue,
resolveMessagePresentationButtonAction,
resolveMessagePresentationControlValue,
resolveMessagePresentationOptionAction,
} from "openclaw/plugin-sdk/interactive-runtime";
Fresh implementations should handle MessagePresentation directly on input and output. The older interactive payload format is a deprecated subset of presentation, and runtime support persists for producers that have not migrated.
Useful helpers that are not deprecated:
normalizeMessagePresentation(raw)/hasMessagePresentationBlocks(value)check and convert an untyped payload, such as JSON coming from the CLI--presentationflag, intoMessagePresentation.isMessagePresentationInteractiveBlock(block)restricts a block to thebuttons|selectunion.resolveMessagePresentationButtonAction(button)andresolveMessagePresentationOptionAction(option)give back the canonical typed action while still accepting deprecated boundary fields. An explicitactiontakes precedence.resolveMessagePresentationActionValue(action)/resolveMessagePresentationControlValue(control)pull only scalar values for commands and callbacks. A non-scalar canonical action never falls back to a legacy shadowvalue, so approval IDs and link targets keep their types.renderMessagePresentationChartFallbackText(block)/renderMessagePresentationTableFallbackText(block)turn one structured data block into deterministic text for channel-specific fallback paths.
The old InteractiveReply* types and their conversion helpers are flagged @deprecated in the SDK:
InteractiveReply,InteractiveReplyBlock,InteractiveReplyButton, andInteractiveReplyOptionnormalizeInteractiveReply(...)hasInteractiveReplyBlocks(...)interactiveReplyToPresentation(...)presentationToInteractiveReply(...)presentationToInteractiveControlsReply(...)resolveInteractiveTextFallback(...)reduceInteractiveReply(...)
presentationToInteractiveReply(...) and presentationToInteractiveControlsReply(...) stay available as renderer bridges for legacy channel implementations. New producer code should avoid them; send presentation and let core or channel adaptation manage rendering.
Approval helpers have presentation-first equivalents too:
- swap
buildApprovalInteractiveReply(...)forbuildApprovalPresentation(...) - swap
buildExecApprovalInteractiveReply(...)forbuildExecApprovalPresentation(...)
Those shipped builders remain command-backed for plugin compatibility. Gateway and bundled channel code that manages a durable approval kind should use buildTypedApprovalPresentation(...), buildTypedExecApprovalPendingReplyPayload(...), or buildTypedPluginApprovalPendingReplyPayload(...) so transports receive an explicit approval action rather than guessing semantics from /approve text.
For presentation blocks lacking a text fallback, such as a divider-only presentation, renderMessagePresentationFallbackText(...) returns an empty string. Transports that need a non-empty send body can pass emptyFallback to opt into a minimal body without altering the default fallback contract.
Delivery pin
Pinning belongs to delivery, not presentation. Choose delivery.pin over provider-native fields like channelData.telegram.pin.
Semantics:
pin: truepins the first message that delivers successfully.pin.notifyhas a default offalse.pin.requiredhas a default offalse.- Optional pin failures degrade and leave the sent message untouched.
- Required pin failures cause delivery to fail.
- Chunked messages pin the first delivered chunk, never the tail chunk.
Manual pin, unpin, and pins message actions still exist for existing messages where the provider supports those operations.
Plugin author checklist
- When the channel supports rendering semantic presentation, or can degrade safely, declare
presentationfromdescribeMessageTool(...). - Add
presentationCapabilitiesto the runtime outbound adapter. - Implement
renderPresentationin runtime code, not in control-plane plugin setup. - Keep native UI libraries out of hot setup and catalog paths.
- Declare generic capability limits on
presentationCapabilities.limitswhen known. - Preserve final platform limits in the renderer and tests.
- Add fallback tests for unsupported charts, tables, buttons, selects, URL buttons, title/text duplication, and mixed
messagepluspresentationsends. - Add delivery pin support through
deliveryCapabilities.pinandpinDeliveredMessageonly when the provider can pin the sent message id. - Do not expose new provider-native card, block, component, or button fields through the shared message action schema.