Interactive Bot Messages

Reply with buttons, dropdowns, and approval prompts that render as native widgets on Telegram, Slack, Discord, and WhatsApp

Agents can reply with structured interactive messages — buttons, dropdowns, dividers, context text — and each channel adapter renders them as native widgets.

python
from praisonaiagents import Agent

agent = Agent(name="assistant", instructions="Offer clear yes/no choices in chat.")
agent.start("Ask the user to approve the deployment with buttons.")

The user taps an inline button or dropdown; each channel renders the same structured presentation natively.

Diagram

One model, four channels

Agent

MessagePresentation

Telegram
Inline Keyboard

Slack
Block Kit

Discord
Components

WhatsApp
Interactive Buttons/List

Quick Start

  1. Reply with two buttons
    python
    from praisonaiagents import Agent
    from praisonaiagents.bots import (
        MessagePresentation,
        PresentationBlock,
        PresentationButton,
        PresentationAction,
    )
    
    def confirm_action(_args=None) -> MessagePresentation:
        return MessagePresentation(blocks=[
            PresentationBlock.make_text("Ready to deploy?"),
            PresentationBlock.make_buttons([
                PresentationButton(
                    label="Deploy",
                    action=PresentationAction(type="command", command="/deploy confirm"),
                    style="primary",
                    priority=10,
                ),
                PresentationButton(
                    label="Cancel",
                    action=PresentationAction(type="command", command="/deploy cancel"),
                    style="danger",
                    priority=9,
                ),
            ]),
        ])
    
    agent = Agent(name="DeployBot", instructions="Confirm before deploying", tools=[confirm_action])
    agent.start("Deploy main to production")
  2. Dropdown selection
    python
    from praisonaiagents.bots import (
        MessagePresentation,
        PresentationBlock,
        SelectOption,
    )
    
    presentation = MessagePresentation(blocks=[
        PresentationBlock.make_text("Pick an environment:"),
        PresentationBlock.make_select(
            options=[
                SelectOption(label="Staging", value="staging", emoji="🧪"),
                SelectOption(label="Production", value="prod", emoji="🚀", default=True),
            ],
            placeholder="Choose an environment",
            action_id="env_select",
        ),
    ])
  3. One-line approval prompt
    python
    from praisonaiagents.bots import MessagePresentation
    
    presentation = MessagePresentation.approval(
        prompt="Allow delete_file for /var/log/old.log?",
        approval_id="appr_abc123",
        allow_always=True,
        context="Tool requested by agent: ops-bot",
    )
  4. One-line question / choice prompt
    python
    from praisonaiagents.bots import MessagePresentation
    
    presentation = MessagePresentation.question(
        prompt="Which environment should I deploy to?",
        options=["staging", "production", "cancel"],
        context="Deployments run immediately.",
    )

    Each option renders as a native button on every channel; the tapped value flows back as the next agent turn — no callback handler needed.

How It Works

Diagram
UserChannelRendererGatewayAgentUserChannelRendererGatewayAgentpriority truncation,label/option caps,select→buttons, web_app→urlGatewayMessage(presentation=…)render_presentation(target, presentation)adapt_presentation(limits)Native widgetInteractive messageButton clickCommand callback
ChannelNative renderingIf unsupported
TelegramInline keyboardselect → button column
SlackBlock Kitweb_app action → URL button
DiscordComponentsweb_app action → URL button
WhatsAppReply buttons (≤3) / List messages (≤10 rows)Buttons >3 or select → list message; URL buttons inlined as text links

Block Types

BlockFactoryUse for
Textmake_text(content)Markdown body
Buttonsmake_buttons(items)Action rows
Selectmake_select(options)Dropdown menus
Dividermake_divider()Visual separator
Contextmake_context(content)Smaller hint text
Tablemake_table(columns, rows)Tabular data
Chartmake_chart(chart_kind, series)Chart/visualisation
Pollmake_poll(question, options)Group vote — native poll or question + reply-buttons degrade

Table and chart blocks default to supports_tables=False / supports_charts=False on every channel, so channel authors get an automatic markdown-table / text-summary degrade for free — see Table & Chart Blocks.

Poll blocks default to supports_native_poll=False on every channel, so a group vote degrades to a question plus reply-buttons until a renderer adds a native-poll branch — see Poll Blocks.

Channel renderers always run adapt_presentation() first. Buttons over the per-channel cap are dropped lowest-priority first, so high-priority actions like Confirm or Deny survive on Discord and Slack.

Channel Adaptation

adapt_presentation() runs automatically inside every renderer — you can also call it yourself to preview what a channel will receive.

Diagram

MessagePresentation

adapt_presentation\nlimits

Truncate by priority

Truncate labels

select → buttons

web_app → URL

Native widget

python
from praisonaiagents.bots import (
    MessagePresentation,
    PresentationBlock,
    PresentationButton,
    PresentationLimits,
    adapt_presentation,
)

buttons = [PresentationButton(label=f"opt {i}", priority=i) for i in range(12)]
presentation = MessagePresentation([PresentationBlock.make_buttons(buttons)])

adapted = adapt_presentation(presentation, PresentationLimits.slack())
# adapted.blocks[0].buttons → highest-priority 5 survive (opt 7..opt 11)

The input presentation is never mutated — adapt_presentation() always returns a new MessagePresentation.

For adapters that want to surface downgrades to the user, use adapt_presentation_with_report() — it returns the adapted presentation plus a typed DegradedDelivery report of what was downgraded (with machine-readable reason codes).

Channel Limits

max_buttons is per-row capacity; total button cap is max_buttons × max_button_rows.

ChannelMax buttons (per row)Max button rowsTotal capMax button labelMax optionsMax option labelSupports selectSupports web_app
Telegram8100800640 (none)—NoYes
Slack5157510075YesNo
Discord55258025100YesNo
WhatsApp10 (list rows) / 3 (reply buttons)110 (list) / 3 (reply)24 (list) / 20 (reply)1024Yes (native list)No

Renderer Registry

Every channel plugs into a platform-keyed registry so adapters resolve a renderer uniformly and unknown channels degrade to readable text.

Diagram

registered

unknown

MessagePresentation

render_for(platform, presentation)

get_renderer(platform)

Native payload

fallback_text()

render_for(platform, presentation) resolves the registered renderer and returns its native payload; channels with no renderer fall back to fallback_text(presentation).

python
from praisonai_bot.bots._presentation_renderer import (
    get_renderer,
    render_for,
    fallback_text,
)
from praisonaiagents.bots import MessagePresentation

presentation = MessagePresentation.approval("Allow file delete?", "appr-1")

# Resolve + render for a known channel
payload = render_for("whatsapp", presentation)   # native interactive payload

# Unknown channels degrade to readable text
render_for("email", presentation)                # → {"text": "...\n• Allow Once\n• Deny"}

get_renderer("slack")   # → SlackPresentationRenderer
get_renderer("email")   # → None

Each renderer implements the PresentationRenderer Protocol — two static methods, get_limits() and render(). Register a new channel by adding a class with these methods to _RENDERERS.

MethodReturnsPurpose
get_limits()PresentationLimitsChannel capability caps used by adapt_presentation()
render(presentation)Dict[str, Any]Native, platform-specific payload

fallback_text(presentation) flattens a presentation for channels without a registered renderer: text/context/divider blocks become lines, buttons and select options become • Label bullets, and URL buttons inline as • Label: URL so nothing is silently dropped.

Native rendering per channel

Call a renderer directly to preview the exact native payload a channel receives.

python
from praisonai_bot.bots._presentation_renderer import WhatsAppPresentationRenderer
from praisonaiagents.bots import MessagePresentation

rendered = WhatsAppPresentationRenderer.render(
    MessagePresentation.approval("Allow file delete?", "appr-1")
)
# rendered["interactive"]["type"] == "button"  → 2 tappable reply buttons

Capability Degradation

When a channel lacks a capability, adapt_presentation() gracefully degrades to the next best option.

select → buttons on Telegram:

python
from praisonaiagents.bots import (
    MessagePresentation,
    PresentationBlock,
    PresentationLimits,
    SelectOption,
    adapt_presentation,
)

# Telegram has supports_select=False — adapt_presentation converts
# select options into a button column with bounded callback payloads.
block = PresentationBlock.make_select(
    options=[
        SelectOption(label="Staging", value="staging"),
        SelectOption(label="Production", value="prod"),
    ],
    action_id="env",
)
presentation = MessagePresentation([block])
adapted = adapt_presentation(presentation, PresentationLimits.telegram())
# → on Telegram, becomes two callback buttons:
#   [{label: "Staging", callback: "select:env:staging"},
#    {label: "Production", callback: "select:env:prod"}]

web_app → URL button on Slack/Discord:

python
from praisonaiagents.bots import (
    MessagePresentation,
    PresentationBlock,
    PresentationButton,
    PresentationAction,
    PresentationLimits,
    ActionType,
    adapt_presentation,
)

# Slack has supports_web_apps=False — adapt_presentation degrades a
# web_app action to a plain URL button so the link still works.
button = PresentationButton(
    label="Open App",
    action=PresentationAction(type=ActionType.WEB_APP, web_app_url="https://example.com"),
)
presentation = MessagePresentation([PresentationBlock.make_buttons([button])])
adapted = adapt_presentation(presentation, PresentationLimits.slack())
# → on Slack, becomes a URL button to https://example.com

Approval Prompts

On channels implementing SupportsPresentation, approval prompts render as inline Allow Once, Allow Always, and Deny buttons wired to /approve <approval_id> ... commands — replacing fragile yes/no text classification.

Text-keyword backends (TelegramApproval, SlackApproval, DiscordApproval) remain valid fallbacks for channels without presentation support.

When the underlying bot uses MessagePresentation.approval(...), the button namespace is automatically actor-bound — see Interactive Callback Authorization.

Long callback values

Two paths keep long callback values usable past a channel’s 64-byte inline-callback cap.

Diagram

Yes

No

Yes (TelegramBot default)

No

📝 reply / select value

Fits in 64 bytes?

📮 Sent inline
reply:value

Store attached?

🔒 Persisted under @ref
reply:@ref → resolves to exact value

#️⃣ Truncated SHA1
reply:#digest (lossy, handler skipped)

TelegramBot shares one InMemoryCallbackPayloadStore between its renderer and inbound registry, so long values round-trip by default. See Interactive Callback Payload Store for a dedicated walkthrough.

Best Practices

Use factory methods for blocks

PresentationBlock.make_text() and make_buttons() are the agent-friendly path — fewer field mistakes than raw dataclass construction.

Set high priority on destructive or critical actions

When a channel forces truncation, adapt_presentation() drops the lowest-priority buttons first. Give Confirm / Deny / Cancel high priority so they always reach the user.

Set button priority for destructive actions

Give Deny or Cancel higher priority so they survive truncation on Discord’s stricter component limits.

Provide plain-text content as fallback

Always set MessagePresentation text content so channels without a registered renderer (e.g. Email) still deliver a readable message. WhatsApp now renders interactive presentations natively.

Use MessagePresentation.approval for tool gates

The built-in helper wires standard Allow/Deny buttons consistently across Telegram, Slack, and Discord.

Use MessagePresentation.question for non-binary clarifications

question(...) is the counterpart to approval(...) for “which of these?” prompts — one call renders native buttons on every channel, and the tapped reply value flows back as the next agent turn.

Related