Presentation Renderers

Core registry that turns a portable MessagePresentation into a native, per-channel payload — built-ins and plugins register through one seam

A core, platform-keyed registry turns one portable MessagePresentation into a native payload for each channel. Built-in channels and pip-installed plugins register through the same seam — register_presentation_renderer — and unknown channels degrade gracefully to text.

Diagram

hit: built-in or plugin

miss

MessagePresentation

render_for(platform)

core registry
(register_presentation_renderer)

Native payload

fallback_text()

Quick Start

  1. Render for a known channel
    python
    from praisonai_bot.bots._presentation_renderer import render_for
    from praisonaiagents.bots import MessagePresentation
    
    presentation = MessagePresentation.approval("Allow file delete?", "appr-1")
    
    payload = render_for("whatsapp", presentation)
    # payload["interactive"]["type"] == "button"  → native reply buttons
  2. Degrade unknown channels to text
    python
    from praisonai_bot.bots._presentation_renderer import render_for
    from praisonaiagents.bots import MessagePresentation
    
    presentation = MessagePresentation.approval("Allow file delete?", "appr-1")
    
    render_for("email", presentation)
    # → {"text": "Allow file delete?\n• Allow Once\n• Deny"}
  3. Register a renderer from a plugin

    Any pip-installed channel plugin plugs a native renderer into the core registry — no wrapper edits required.

    python
    # In your plugin's setup hook, entry point, or module import:
    from praisonaiagents.bots import register_presentation_renderer
    from my_matrix_plugin.renderer import MatrixPresentationRenderer
    
    register_presentation_renderer("matrix", MatrixPresentationRenderer)
    
    # Anywhere else in the app:
    from praisonai_bot.bots._presentation_renderer import render_for
    render_for("matrix", presentation)  # → native Matrix payload

How It Works

render_for(platform, presentation) resolves the platform’s renderer through the core registry and returns its native payload; unregistered channels fall back to fallback_text.

Diagram
RendererCore RegistryAdapterRendererCore RegistryAdapteralt[platform registered (built-in or plugin)][no renderer]render_for(platform, presentation)get_presentation_renderer(platform)render(presentation)native payloadfallback_text(presentation)

The registry lives in core (praisonaiagents.bots); the wrapper (praisonai_bot) only resolves through it and registers the four built-ins at import time.

FunctionLayerReturnsPurpose
register_presentation_renderer(platform, renderer)core (praisonaiagents.bots)NoneRegister a native renderer for a channel (built-in or plugin)
get_presentation_renderer(platform)core (praisonaiagents.bots)type or NoneLook up the core registry (case-insensitive)
get_renderer(platform)wrapper (praisonai_bot)type or NoneResolves through core, falls back to the built-in map for older-core resilience
render_for(platform, presentation)wrapper (praisonai_bot)Dict[str, Any]Render natively (through get_renderer), else fallback_text
fallback_text(presentation)wrapper (praisonai_bot)Dict[str, Any]Flatten to a readable {"text": ...} payload

The PresentationRendererProtocol

Every renderer implements PresentationRendererProtocol — two @staticmethod methods. The protocol is @runtime_checkable, so isinstance(cls, PresentationRendererProtocol) works.

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

Each renderer runs adapt_presentation against its own get_limits() before mapping blocks, so button overflow, unsupported selects/web-apps, and label truncation are applied uniformly.

Both import paths are valid:

python
from praisonaiagents.bots import PresentationRendererProtocol              # preferred
from praisonaiagents.bots.presentation import PresentationRendererProtocol  # also valid (back-compat)

register_presentation_renderer and get_presentation_renderer are also exported from the top-level praisonaiagents.bots package — no need to reach into the presentation submodule.


Validation & Precedence

Registration fails loudly on misuse, and lookups are case-insensitive.

TriggerBehaviour
platform is empty / whitespace-only / non-stringValueError at registration time
renderer missing callable get_limits or renderTypeError at registration time
Re-registering an already-registered platformSilently overrides — plugins supersede built-ins by design
Mixed-case platform id ("Matrix" vs "matrix")Normalized to lowercase in registration and lookup

The wrapper registers each built-in only if the core slot is empty, so a plugin that registers first for "telegram" wins — the built-in never clobbers it.

Diagram

Yes: plugin OR wrapper-registered built-in

No

Yes

No

render_for(platform, …)

core registry has it?

Renderer.render → native payload

wrapper _BUILTIN_RENDERERS has it?
(older-core fallback)

fallback_text() → plain text


Built-in Renderers

PlatformRendererPayload shape
telegramTelegramPresentationRenderer{"text", "reply_markup": {"inline_keyboard": ...}}
slackSlackPresentationRenderer{"blocks": [...]} (Block Kit)
discordDiscordPresentationRenderer{"content", "components": [...]}
whatsappWhatsAppPresentationRenderer`{“text”, “interactive”: {“type”: “button” \“list”, …}}`

The wrapper registers all four through the core seam at import time.


Graceful Degradation

fallback_text(presentation) keeps interactive content readable for channels without a renderer — nothing is silently dropped.

python
from praisonai_bot.bots._presentation_renderer import fallback_text
from praisonaiagents.bots import (
    MessagePresentation,
    PresentationBlock,
    PresentationButton,
    PresentationAction,
)

presentation = MessagePresentation(blocks=[
    PresentationBlock.make_text("Docs?"),
    PresentationBlock.make_buttons([
        PresentationButton(
            label="Open",
            action=PresentationAction.open_url("https://example.com"),
        ),
    ]),
])

fallback_text(presentation)
# → {"text": "Docs?\n• Open: https://example.com"}

Text/context/divider blocks become lines, buttons and select options become • Label bullets, and URL buttons inline as • Label: URL.


Add a Channel

Write a class with the two static methods, then register it through the public core seam — register_presentation_renderer.

python
from typing import Any, Dict
from praisonaiagents.bots import (
    MessagePresentation,
    PresentationLimits,
    adapt_presentation,
    register_presentation_renderer,
)

class MyChannelRenderer:
    @staticmethod
    def get_limits() -> PresentationLimits:
        return PresentationLimits()

    @staticmethod
    def render(presentation: MessagePresentation) -> Dict[str, Any]:
        presentation = adapt_presentation(presentation, MyChannelRenderer.get_limits())
        # map blocks to your channel's native payload
        return {"text": "..."}

register_presentation_renderer("mychannel", MyChannelRenderer)

render_for("mychannel", presentation) — and render_for("MyChannel", ...), render_for("MYCHANNEL", ...) — now all resolve the same renderer.


Best Practices

Register through the core seam, not a private dict

Call register_presentation_renderer(platform, MyRenderer) from praisonaiagents.bots. That is the only registration visible to the resolution path everywhere else — a plugin renderer becomes indistinguishable from a built-in.

Always adapt before mapping blocks

Run adapt_presentation(presentation, get_limits()) first so button overflow, label caps, and unsupported selects/web-apps degrade uniformly before you build the native payload.

Use render_for, not the class directly, in adapters

render_for(platform, presentation) resolves the renderer and falls back to text for unknown platforms, so adapters stay channel-agnostic.

Never drop interactive content

When a channel can’t render a widget, surface it as text (labels, URLs) the way fallback_text does — a silently dropped button is worse than a text link.

Return the id shape your callback layer expects

Reply/list-row ids are how taps route back to your handler. Derive stable ids (command, callback value, or URL) and keep them within the channel’s id cap.


Related