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.
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.
Quick Start
- 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") - 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", ), ]) - 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", ) - 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
| Channel | Native rendering | If unsupported |
|---|---|---|
| Telegram | Inline keyboard | select → button column |
| Slack | Block Kit | web_app action → URL button |
| Discord | Components | web_app action → URL button |
| Reply buttons (≤3) / List messages (≤10 rows) | Buttons >3 or select → list message; URL buttons inlined as text links |
Block Types
| Block | Factory | Use for |
|---|---|---|
| Text | make_text(content) | Markdown body |
| Buttons | make_buttons(items) | Action rows |
| Select | make_select(options) | Dropdown menus |
| Divider | make_divider() | Visual separator |
| Context | make_context(content) | Smaller hint text |
| Table | make_table(columns, rows) | Tabular data |
| Chart | make_chart(chart_kind, series) | Chart/visualisation |
| Poll | make_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.
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.
| Channel | Max buttons (per row) | Max button rows | Total cap | Max button label | Max options | Max option label | Supports select | Supports web_app |
|---|---|---|---|---|---|---|---|---|
| Telegram | 8 | 100 | 800 | 64 | 0 (none) | — | No | Yes |
| Slack | 5 | 1 | 5 | 75 | 100 | 75 | Yes | No |
| Discord | 5 | 5 | 25 | 80 | 25 | 100 | Yes | No |
| 10 (list rows) / 3 (reply buttons) | 1 | 10 (list) / 3 (reply) | 24 (list) / 20 (reply) | 10 | 24 | Yes (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.
render_for(platform, presentation) resolves the registered renderer and returns its native payload; channels with no renderer fall back to fallback_text(presentation).
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") # → NoneEach 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.
| Method | Returns | Purpose |
|---|---|---|
get_limits() | PresentationLimits | Channel 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.
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 buttonsCapability Degradation
When a channel lacks a capability, adapt_presentation() gracefully degrades to the next best option.
select → buttons on Telegram:
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:
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.comApproval 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.
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.