Summary
PraisonAI has a well-designed, typed portable presentation model: MessagePresentation with a discriminated ActionType union (command, callback, url, web_app) renders to native UI on each platform without string inference. But the loop is only half-built: the render side is typed and the receive side is not wired. When a user taps a button or picks a select option, the resulting callback is dropped by every channel adapter except for the narrow pair: pairing case. So any agent or workflow that renders interactive controls (action buttons, confirmation choices, menus) produces UI that does nothing on click. This makes a flagship "world-class, easy-to-use" capability effectively non-functional for real interactive flows.
Current behaviour
The render side correctly dispatches on the typed action and encodes it into transport-private callback data:
# src/praisonai/praisonai/bots/_presentation_renderer.py (TelegramPresentationRenderer.render)
elif button.action.type == "command" and button.action.command:
button_data["callback_data"] = f"cmd:{button.action.command}"[:64]
elif button.action.type == "callback" and button.action.value:
button_data["callback_data"] = button.action.value[:64]
The action union itself is a clean, typed core contract:
# src/praisonai-agents/praisonaiagents/bots/presentation.py:19
class ActionType(str, Enum):
COMMAND = "command" # Execute a slash command
CALLBACK = "callback" # Opaque callback data for the plugin
URL = "url"
WEB_APP = "web_app"
But on the inbound side, the Telegram adapter's callback handler ignores everything that is not a pairing callback:
# src/praisonai/praisonai/bots/telegram.py:623
async def handle_callback_query(update, context):
query = update.callback_query
if not query or not query.data:
return
# Only handle pairing callbacks
if not query.data.startswith("pair:"):
return # <-- cmd:/callback actions emitted by the renderer are dropped
...
There is no registry or protocol that decodes a callback envelope back into a typed action and routes it to a handler. bots/protocols.py defines MessageType.CALLBACK = "callback" (src/praisonai-agents/praisonaiagents/bots/protocols.py:144) but no inbound action-dispatch contract sits alongside the typed render contract in bots/presentation.py. The result is a structural asymmetry: distinguishable, typed actions go out; nothing typed comes back in. Approval flows paper over this with their own per-platform string-keyword//approve command parsing (_presentation_approval.py, _approval_base.classify_keyword), which only works because approvals re-encode themselves as slash commands — it does not generalise to arbitrary rendered buttons/selects.
Desired behaviour
A single, typed inbound dispatch path symmetric with the render side:
- Callback data is decoded into a structured
{namespace, payload} (or typed action) and routed to a registered handler, instead of each adapter pattern-matching raw strings.
- Any agent/workflow that renders a button/select can register a handler and reliably receive the tap/selection on every channel that supports interactivity.
- Raw callback data stays transport-private; product semantics never leak into string prefixes the adapter has to guess at.
Layer placement
- Primary layer: core (
src/praisonai-agents/praisonaiagents/bots/) — the inbound dispatch registry/protocol belongs next to the existing typed render contract in presentation.py/protocols.py, defined once and shared by all adapters.
- Why not wrapper: putting the decoder only in the wrapper duplicates ad-hoc parsing per adapter (which is exactly today's gap); the contract should be a single core protocol.
- Why not tools: this is a transport/presentation contract, not an agent-callable integration.
- Why not plugins: it is core interactive plumbing, not a lifecycle guardrail.
- Secondary touch: wrapper — each channel adapter (
bots/telegram.py, discord.py, slack.py) maps its native callback envelope into the core decoder instead of returning early on a pair: prefix.
- 3-way surface (CLI + YAML + Python): partial — primarily a Python/SDK + adapter contract; no new YAML/CLI surface required.
Proposed approach
- Extension point: a core interactive-action registry + decode/dispatch protocol mirroring
ActionType.
- Minimal API sketch:
# core: praisonaiagents/bots/presentation.py (or a new interactive.py)
def encode_action(namespace: str, action: PresentationAction) -> str: ...
def decode_callback(data: str) -> tuple[str, PresentationAction]: ... # {namespace, payload}
class InteractiveRegistry:
def register(self, namespace: str, handler: Callable[[InteractiveContext], Awaitable]): ...
async def dispatch(self, data: str, ctx: InteractiveContext) -> bool: ...
# wrapper: bots/telegram.py
async def handle_callback_query(update, context):
data = update.callback_query.data
if await self._interactive.dispatch(data, ctx): # typed, registry-routed
return
# pairing remains one registered namespace among others
Resolution sketch
# Before (today)
agent.render(MessagePresentation(blocks=[Buttons([
Button("Approve", action=Action(type="callback", value="approve:42"))])]))
# user taps -> telegram handle_callback_query sees data not starting with "pair:" -> returns -> nothing happens
# After (proposed)
registry.register("approve", on_approve) # handler gets typed payload
# render encodes namespace+payload; on tap, decode_callback -> ("approve", payload) -> on_approve(ctx)
Severity
High — interactive controls are a headline, "few lines of code" capability, but today rendered buttons/selects are inert on click for everything except pairing, so the feature is misleading and unusable for real flows.
Validation
Confirmed by tracing render→receive in PraisonAI: typed render in src/praisonai/praisonai/bots/_presentation_renderer.py (encodes cmd:/raw callback) against the inbound handler src/praisonai/praisonai/bots/telegram.py:629 (if not query.data.startswith("pair:"): return). The typed action union exists in src/praisonai-agents/praisonaiagents/bots/presentation.py:19, but bots/protocols.py (with MessageType.CALLBACK) contains no symmetric inbound action-dispatch contract — only approval flows decode anything, and only via their own slash-command re-encoding.
Summary
PraisonAI has a well-designed, typed portable presentation model:
MessagePresentationwith a discriminatedActionTypeunion (command,callback,url,web_app) renders to native UI on each platform without string inference. But the loop is only half-built: the render side is typed and the receive side is not wired. When a user taps a button or picks a select option, the resulting callback is dropped by every channel adapter except for the narrowpair:pairing case. So any agent or workflow that renders interactive controls (action buttons, confirmation choices, menus) produces UI that does nothing on click. This makes a flagship "world-class, easy-to-use" capability effectively non-functional for real interactive flows.Current behaviour
The render side correctly dispatches on the typed action and encodes it into transport-private callback data:
The action union itself is a clean, typed core contract:
But on the inbound side, the Telegram adapter's callback handler ignores everything that is not a pairing callback:
There is no registry or protocol that decodes a callback envelope back into a typed action and routes it to a handler.
bots/protocols.pydefinesMessageType.CALLBACK = "callback"(src/praisonai-agents/praisonaiagents/bots/protocols.py:144) but no inbound action-dispatch contract sits alongside the typed render contract inbots/presentation.py. The result is a structural asymmetry: distinguishable, typed actions go out; nothing typed comes back in. Approval flows paper over this with their own per-platform string-keyword//approvecommand parsing (_presentation_approval.py,_approval_base.classify_keyword), which only works because approvals re-encode themselves as slash commands — it does not generalise to arbitrary rendered buttons/selects.Desired behaviour
A single, typed inbound dispatch path symmetric with the render side:
{namespace, payload}(or typed action) and routed to a registered handler, instead of each adapter pattern-matching raw strings.Layer placement
src/praisonai-agents/praisonaiagents/bots/) — the inbound dispatch registry/protocol belongs next to the existing typed render contract inpresentation.py/protocols.py, defined once and shared by all adapters.bots/telegram.py,discord.py,slack.py) maps its native callback envelope into the core decoder instead of returning early on apair:prefix.Proposed approach
ActionType.Resolution sketch
Severity
High — interactive controls are a headline, "few lines of code" capability, but today rendered buttons/selects are inert on click for everything except pairing, so the feature is misleading and unusable for real flows.
Validation
Confirmed by tracing render→receive in PraisonAI: typed render in
src/praisonai/praisonai/bots/_presentation_renderer.py(encodescmd:/raw callback) against the inbound handlersrc/praisonai/praisonai/bots/telegram.py:629(if not query.data.startswith("pair:"): return). The typed action union exists insrc/praisonai-agents/praisonaiagents/bots/presentation.py:19, butbots/protocols.py(withMessageType.CALLBACK) contains no symmetric inbound action-dispatch contract — only approval flows decode anything, and only via their own slash-command re-encoding.