Message presentation is Apus'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: text sections, small context/footer text, dividers, charts, tables, buttons, select menus, and card title/tone.

Do not add provider-native fields to the shared message tool by default. A native extension requires explicit maintainer approval, a channel-owned schema, documented cross-channel behavior, and capabilities that presentation cannot express. Discord components is the approved built-in exception for modal forms, media galleries, file blocks, sections with accessories, and styled containers. Use presentation for portable rich messages.

Contract

Plugin authors import the public contract from:

import type {
  MessagePresentation,
  ReplyPayloadDelivery,
} from "apus/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: "question";
      questionId: string;
      intent: "custom-input";
    }
  | { 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:

Select semantics:

Chart semantics:

Table semantics:

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

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",
          "action": { "type": "callback", "value": "deploy:approve" },
          "style": "success"
        },
        {
          "label": "Decline",
          "action": { "type": "callback", "value": "deploy:decline" },
          "style": "danger"
        }
      ]
    }
  ]
}

URL-only link button:

{
  "blocks": [
    { "type": "text", "text": "Release notes are ready." },
    {
      "type": "buttons",
      "buttons": [
        {
          "label": "Open notes",
          "action": { "type": "url", "url": "https://example.com/release" }
        }
      ]
    }
  ]
}

Telegram Mini App button:

{
  "blocks": [
    {
      "type": "buttons",
      "buttons": [
        {
          "label": "Launch",
          "action": { "type": "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" }
      ]
    }
  ]
}

Chart:

{
  "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"
    }
  ]
}

Table 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." }
  ]
}

CLI send:

apus 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:

apus message send --channel telegram \
  --target -1001234567890 \
  --message "Topic opened" \
  --pin

Pinned delivery with explicit JSON:

{
  "pin": {
    "enabled": true,
    "notify": true,
    "required": false
  }
}

Renderer contract

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,
    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, assertDirectAdapterHandoff }) {
    assertDirectAdapterHandoff?.();
    await pinNativeMessage(target, messageId, { notify: pin.notify === true });
  },
};

When a capability depends on per-account configuration or the delivery's text funnel — for example Telegram only renders native tables on richMessages accounts and only on the markdown path — declare the optional resolvePresentationCapabilities({ cfg, accountId, formatting }) hook next to the static object. Core resolves capabilities once per delivery and the hook takes precedence over the static declaration; keep the static object as the account-independent baseline.

const adapter: ChannelOutboundAdapter = {
  presentationCapabilities: BASE_CAPABILITIES,
  resolvePresentationCapabilities: ({ cfg, accountId, formatting }) => ({
    ...BASE_CAPABILITIES,
    tables: isRichAccount(cfg, accountId) && formatting?.parseMode !== "HTML",
  }),
  // ...
};

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;
  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;
    };
  };
};

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.

Core render flow

On the canonical outbound path used by CLI and standard message actions, core:

  1. Normalizes the presentation payload.
  2. Resolves the target channel's outbound adapter.
  3. Reads presentationCapabilities.
  4. Applies generic capability limits such as action count, label length, and select option count when the adapter advertises them. Chart and table blocks become deterministic text unless the adapter explicitly advertises charts: true or tables: true, respectively.
  5. Calls renderPresentation when the adapter can render the payload. Its presentation is adapted for native limits; sourcePresentation retains the normalized original for channel-specific text fallbacks.
  6. Falls back to conservative text when the adapter is absent or cannot render.
  7. Sends the resulting payload through the normal channel delivery path.
  8. Applies delivery metadata such as delivery.pin after the first successful sent message.

Channel-local reply or preview funnels that consume ReplyPayload directly must either enter that canonical path or materialize the same presentation fallback before projecting the payload down to plain text/media.

Core owns fallback behavior so producers can stay channel-agnostic. Channel plugins own native rendering and interaction handling. When a renderer falls back to text because its complete native card cannot fit, use sourcePresentation to preserve full labels and apply the channel's text and URL sanitation. Continue using presentation for native controls.

Degradation rules

Presentation must be safe to send on limited channels.

Producers that hand-author the plain rendering of the same facts can mark it with presentationTextMode: "fallback" on the reply payload. Channels that render the presentation's data blocks natively drop that text; when every table and chart block degrades and no interactive block remains, the authored text ships verbatim instead of the generic flatten below.

Fallback text includes:

Button value fallback visibility

When a channel cannot render interactive controls, button and select values fall back to plain text. The fallback behavior preserves usability while keeping opaque callback data private:

Channel adapters that add manual-command guidance in their fallback UI (e.g. Feishu document-comment instructions) must derive the command-present check from the same presentation blocks that the fallback renderer uses, so the guidance text only appears when a manual command is actually shown.

Unsupported 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.

Provider mapping

Current bundled renderers:

Channel Native render target Notes
Discord Components and component containers Supports the documented Discord-specific components extension for native layouts that presentation cannot express. Portable shared sends should use presentation.
Feishu Interactive cards Card header uses title once. Within native cards, disabled or unsupported buttons retain label-only text; rejected URL targets and opaque callback values are omitted.
Matrix Text fallback plus structured event field Buttons/selects advertise as supported, but every block renders as renderMessagePresentationFallbackText output carried in a com.apus.presentation event field, not native interactive widgets.
Mattermost Text plus interactive props Selects and dividers are not supported; those blocks degrade to text.
Microsoft Teams Adaptive Cards Plain message text is included with the card when both are provided. Selects, styles, and disabled state are not supported.
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 use presentation.
Telegram Text plus inline keyboards Buttons/selects require inline button capability for the target surface; otherwise text fallback is used.
Plain channels Text fallback Channels without a renderer still get readable output.

Provider-native payload compatibility is kept for existing reply producers only. New native fields require the explicit exception review above.

Presentation vs InteractiveReply

InteractiveReply is the older internal subset used by approval and interaction helpers. It supports:

MessagePresentation is the canonical shared send contract. It adds:

Use helpers from apus/plugin-sdk/interactive-runtime when bridging older code:

import {
  adaptMessagePresentationForChannel,
  applyPresentationActionLimits,
  hasMessagePresentationBlocks,
  interactiveReplyToPresentation,
  isMessagePresentationInteractiveBlock,
  normalizeMessagePresentation,
  presentationPageSize,
  presentationToInteractiveControlsReply,
  presentationToInteractiveReply,
  renderMessagePresentationChartFallbackText,
  renderMessagePresentationFallbackText,
  renderMessagePresentationTableFallbackText,
  resolveMessagePresentationActionValue,
  resolveMessagePresentationButtonAction,
  resolveMessagePresentationControlValue,
  resolveMessagePresentationOptionAction,
} from "apus/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.

Non-deprecated helpers worth knowing:

The legacy InteractiveReply* types and conversion helpers are marked @deprecated in the SDK. The compatibility registry records them as message-presentation-legacy-bridges, deprecated on 2026-07-25 with a removeAfter date of 2026-10-01:

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:

Those shipped builders remain command-backed for plugin compatibility. Gateway and bundled channel code that owns a durable approval kind should use buildTypedApprovalPresentation(...), buildTypedExecApprovalPendingReplyPayload(...), or buildTypedPluginApprovalPendingReplyPayload(...) so transports receive an explicit approval action instead of inferring semantics from /approve text.

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.

Delivery pin

Pinning is delivery behavior, not presentation. Use delivery.pin instead of provider-native fields such as channelData.telegram.pin.

Semantics:

Manual pin, unpin, and pins message actions still exist for existing messages where the provider supports those operations.

The pinDeliveredMessage context includes an optional assertDirectAdapterHandoff callback. A successful send does not keep its owner active indefinitely: the owner can close while the adapter prepares or waits to pin that message. Core checks the assertion before entering the pin hook, and the adapter must retain it through preparation, rate-limit queues, and retries, checking it synchronously immediately before each provider request. If the native pin helper performs those waits, forward the assertion into its existing request guard too.

Let a submitted pin request settle even if authority closes while awaiting its response. Do not discard an accepted pin or repeat the message send. This optional context field preserves older callers that do not supply an assertion; it adds no configuration or pin permission and must not be serialized into a payload.

Plugin author checklist

Related docs