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;
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.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.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" }]
}
]
}
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,
},
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 fields are intentionally simple booleans. They describe what the renderer can make interactive, not every native platform limit. Renderers still own platform-specific limits such as maximum button count, block count, and card size.
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 {
interactiveReplyToPresentation,
normalizeMessagePresentation,
presentationToInteractiveReply,
renderMessagePresentationFallbackText,
} from "openclaw/plugin-sdk/interactive-runtime";
New code should accept or produce MessagePresentation directly.
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.message plus presentation sends.deliveryCapabilities.pin and
pinDeliveredMessage only when the provider can pin the sent message id.