docs/plugins/message-presentation.md
Message presentation is OpenClaw's shared contract for rich outbound chat UI. It lets agents, CLI commands, approval flows, and plugins describe the message intent once, while each channel plugin renders the best native shape it can.
Use presentation for portable message UI:
Do not add new provider-native fields such as Discord components, Slack
blocks, Telegram buttons, Teams card, or Feishu card to the shared
message tool. Those are renderer outputs owned by the channel plugin.
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 MessagePresentationButton = {
label: string;
value?: string;
url?: string;
webApp?: { url: string };
/** @deprecated Use webApp. Accepted for legacy JSON payloads only. */
web_app?: { url: string };
priority?: number;
disabled?: boolean;
style?: "primary" | "secondary" | "success" | "danger";
};
type MessagePresentationOption = {
label: string;
value: string;
};
type ReplyPayloadDelivery = {
pin?:
| boolean
| {
enabled: boolean;
notify?: boolean;
required?: boolean;
};
};
Button semantics:
value is an application action value routed back through the channel's
existing interaction path when the channel supports clickable controls.url is a link button. It can exist without value.webApp describes a channel-native web app button. Telegram renders this
as web_app and only supports it in private chats. web_app is still
accepted in loose JSON payloads for compatibility, but TypeScript producers
should use webApp.label is required and is also used in text fallback.style is advisory. Renderers should map unsupported styles to a safe
default, not fail the send.priority is 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.disabled is optional. Channels must opt in with supportsDisabled; otherwise
core degrades the disabled control to non-interactive fallback text.Select semantics:
options[].value is the selected application value.placeholder is advisory and may be ignored by channels without native
select support.Simple card:
{
"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", "value": "deploy:approve", "style": "success" },
{ "label": "Decline", "value": "deploy:decline", "style": "danger" }
]
}
]
}
URL-only link button:
{
"blocks": [
{ "type": "text", "text": "Release notes are ready." },
{
"type": "buttons",
"buttons": [{ "label": "Open notes", "url": "https://example.com/release" }]
}
]
}
Telegram Mini App button:
{
"blocks": [
{
"type": "buttons",
"buttons": [{ "label": "Launch", "web_app": { "url": "https://example.com/app" } }]
}
]
}
Select menu:
{
"title": "Choose environment",
"blocks": [
{
"type": "select",
"placeholder": "Environment",
"options": [
{ "label": "Canary", "value": "env:canary" },
{ "label": "Production", "value": "env:prod" }
]
}
]
}
CLI send:
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"}]}]}'
Pinned delivery:
openclaw message send --channel telegram \
--target -1001234567890 \
--message "Topic opened" \
--pin
Pinned delivery with explicit JSON:
{
"pin": {
"enabled": true,
"notify": true,
"required": false
}
}
Channel plugins declare render support on their outbound adapter:
const adapter: ChannelOutboundAdapter = {
deliveryMode: "direct",
presentationCapabilities: {
supported: true,
buttons: true,
selects: true,
context: true,
divider: true,
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 });
},
};
Capability booleans describe what the renderer can make interactive. Optional
limits describe the generic envelope core can adapt before calling the
renderer:
type ChannelPresentationCapabilities = {
supported?: boolean;
buttons?: boolean;
selects?: boolean;
context?: boolean;
divider?: 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;
};
};
};
Core applies generic limits to semantic controls before rendering. Renderers still own final provider-specific validation and clipping for native block count, card size, URL limits, and provider quirks that cannot be expressed in the generic contract. If limits remove every control from a block, core keeps the labels as non-interactive context text so the delivered message still has a visible fallback.
When a ReplyPayload or message action includes presentation, core:
presentationCapabilities.renderPresentation when the adapter can render the payload.delivery.pin after the first successful
sent message.Core owns fallback behavior so producers can stay channel-agnostic. Channel plugins own native rendering and interaction handling.
Presentation must be safe to send on limited channels.
Fallback text includes:
title as the first linetext blocks as normal paragraphscontext blocks as compact context linesdivider blocks as a visual separatorUnsupported native controls should degrade rather than fail the whole send. Examples:
The main exception is delivery.pin.required: true; if pinning is requested as
required and the channel cannot pin the sent message, delivery reports failure.
Current bundled renderers:
| Channel | Native render target | Notes |
|---|---|---|
| Discord | Components and component containers | Preserves legacy channelData.discord.components for existing provider-native payload producers, but new shared sends should use presentation. |
| Slack | Block Kit | Preserves legacy channelData.slack.blocks for existing provider-native payload producers, but new shared sends should use presentation. |
| Telegram | Text plus inline keyboards | Buttons/selects require inline button capability for the target surface; otherwise text fallback is used. |
| Mattermost | Text plus interactive props | Other blocks degrade to text. |
| Microsoft Teams | Adaptive Cards | Plain message text is included with the card when both are provided. |
| Feishu | Interactive cards | Card header can use title; body avoids duplicating that title. |
| Plain channels | Text fallback | Channels without a renderer still get readable output. |
Provider-native payload compatibility is a transition affordance for existing reply producers. It is not a reason to add new shared native fields.
InteractiveReply is the older internal subset used by approval and interaction
helpers. It supports:
MessagePresentation is the canonical shared send contract. It adds:
ReplyPayload.deliveryUse helpers from openclaw/plugin-sdk/interactive-runtime when bridging older
code:
import {
adaptMessagePresentationForChannel,
applyPresentationActionLimits,
interactiveReplyToPresentation,
normalizeMessagePresentation,
presentationPageSize,
presentationToInteractiveControlsReply,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
New code should accept or produce MessagePresentation directly. Existing
interactive payloads are a deprecated subset of presentation; runtime
support remains for older producers.
The legacy InteractiveReply* types and conversion helpers are marked
@deprecated in the SDK:
InteractiveReply, InteractiveReplyBlock, InteractiveReplyButton,
InteractiveReplyOption, InteractiveReplySelectBlock, and
InteractiveReplyTextBlocknormalizeInteractiveReply(...)hasInteractiveReplyBlocks(...)interactiveReplyToPresentation(...)presentationToInteractiveReply(...)presentationToInteractiveControlsReply(...)resolveInteractiveTextFallback(...)reduceInteractiveReply(...)presentationToInteractiveReply(...) and
presentationToInteractiveControlsReply(...) remain available as renderer
bridges for legacy channel implementations. New producer code should not call
them; send presentation and let core/channel adaptation handle rendering.
Approval helpers also have presentation-first replacements:
buildApprovalPresentationFromActionDescriptors(...) instead of
buildApprovalInteractiveReplyFromActionDescriptors(...)buildApprovalPresentation(...) instead of
buildApprovalInteractiveReply(...)buildExecApprovalPresentation(...) instead of
buildExecApprovalInteractiveReply(...)renderMessagePresentationFallbackText(...) returns an empty string for
presentation blocks that have no text fallback, such as a divider-only
presentation. Transports that require a non-empty send body can pass
emptyFallback to opt into a minimal body without changing the default fallback
contract.
Pinning is delivery behavior, not presentation. Use delivery.pin instead of
provider-native fields such as channelData.telegram.pin.
Semantics:
pin: true pins the first successfully delivered message.pin.notify defaults to false.pin.required defaults to false.Manual pin, unpin, and pins message actions still exist for existing
messages where the provider supports those operations.
presentation from describeMessageTool(...) when the channel can
render or safely degrade semantic presentation.presentationCapabilities to the runtime outbound adapter.renderPresentation in runtime code, not control-plane plugin
setup code.presentationCapabilities.limits when
they are known.message plus presentation sends.deliveryCapabilities.pin and
pinDeliveredMessage only when the provider can pin the sent message id.