Bot Platform Capabilities

Declare what a messaging platform can do — Praison uses it for uniform streaming, chunking, and rate limiting

Platform capabilities tell PraisonAI what your bot’s platform can do, so streaming, chunking, and rate limiting work the same way everywhere.

python
from praisonaiagents import Agent

agent = Agent(name="assistant", instructions="Reply on Telegram with streaming when supported.")
agent.start("Send a long answer with progressive updates.")

The user receives a reply; platform capabilities tell PraisonAI how to chunk, stream, and rate-limit on that channel.

Diagram

Bot Adapter

PlatformCapabilities

Unified Delivery

Chunk

Stream

Rate limit

User

Quick Start

  1. Look up built-in capabilities
    python
    from praisonai import Bot
    from praisonaiagents import Agent
    from praisonai.bots._registry import get_platform_capabilities
    
    agent = Agent(name="assistant", instructions="Be helpful")
    caps = get_platform_capabilities("telegram")
    print(caps.max_message_length)  # 4096
    print(caps.length_unit)         # utf16
    
    bot = Bot("telegram", agent=agent)
  2. Register a custom platform
    python
    from praisonaiagents.bots import PlatformCapabilities
    from praisonai.bots._registry import register_platform
    
    class MyBot:
        async def start(self): ...
        async def stop(self): ...
    
    register_platform(
        "mybot",
        MyBot,
        capabilities=PlatformCapabilities(
            max_message_length=2000,
            supports_edit=True,
            markdown_dialect="markdown",
        ),
    )

How it works

UnifiedDelivery (via create_delivery(bot)) reads platform_capabilities to chunk long replies, stream edits, and apply rate limits.

Diagram
PlatformUnifiedDeliveryBotUserPlatformUnifiedDeliveryBotUsermessagesend/stream(text)read capabilitieschunked / edited messagedelivery

Configuration options

FieldTypeDefaultDescription
max_message_lengthint4096Maximum message length in the platform’s unit
length_unitstr"codepoints""codepoints" or "utf16"
supports_editboolFalseIn-place message edits (streaming)
supports_typingboolTrueTyping indicators
supports_deleteboolFalseWhether the platform supports deleting a sent message. When True, the adapter MUST override delete_message; verified at build time by the capability contract. See Adapter Capability Contract.
markdown_dialectstr"markdown"Rendering flavour consumed by format_for_dialect. One of "telegram_markdown_v2", "slack", "discord_markdown", or "markdown" (safe plain-text fallback for unknown values). See How markdown_dialect is consumed.
needs_rate_limitboolTrueApply Praison rate limiting
edit_interval_msint1000Minimum ms between edits
max_files_per_messageint1Attachments per message
max_file_size_mbint10Max file size in MB
supported_file_typesList[str]["*"]Allowed mime types or extensions
accepts_webhooksboolFalseChannel receives inbound HTTP webhooks
verifies_webhook_signatureboolFalseAdapter exposes a webhook verifier
reconciles_unknown_sendboolFalseAdapter can confirm whether a prior send actually landed (via was_delivered(idempotency_key)). Enables effectively-once delivery via the durable outbox.
supports_idempotency_tokenboolFalseInformational — transport accepts a provider-level idempotency token. Set reconciles_unknown_send as well if you need effectively-once.
supports_status_textboolFalseWhether the adapter can render a short, replaceable textual status line (e.g. 🔍 Searching the web...) without editing a message or adding reactions. When True, the adapter SHOULD implement async set_status_text(channel_id, text) (an empty text clears the line at turn end). Streaming auto/progress on a channel that can’t edit then routes the activity line there instead of degrading to off. See Textual Status Line and Streaming Replies.

Methods: to_dict() and from_dict(data) — both round-trip supports_delete and supports_status_text alongside the other flags. Both are appended last in the dataclass on purpose, preserving the positional constructor order for existing callers.

length_unit — UTF-16 vs codepoints

length_unit decides how a reply’s length is measured before chunking. "codepoints" (the default) counts one character as one unit. "utf16" counts in UTF-16 code units, which is what Telegram enforces its 4096 cap in.

The difference shows up on emoji and CJK text. A single emoji is 1 codepoint but 2 UTF-16 code units:

python
from praisonai.bots._chunk import _calculate_length

reply = "😀" * 200
print(_calculate_length(reply, "codepoints"))  # 200
print(_calculate_length(reply, "utf16"))       # 400

So a 200-emoji reply is well under 4096 codepoints but is measured as 400 UTF-16 units on Telegram. A longer emoji-heavy reply that looks safe by codepoint count can still exceed Telegram’s 4096-unit cap — measuring in utf16 chunks it at the point the platform actually enforces, so the message is accepted instead of rejected as “message too long”.

length_unit also drives the streaming path: DraftStreamer truncates the live draft and decides whether to chunk the final answer in this unit, so an emoji- or CJK-heavy Telegram stream is split where Telegram’s 4096-unit limit actually bites (PR #6081).

python
from praisonai.bots._registry import get_platform_capabilities

caps = get_platform_capabilities("telegram")
print(caps.length_unit)  # utf16

Delivery reconciliation (reconciles_unknown_send)

After a gateway restart, can the adapter confirm that a client-side-keyed send actually landed — so a recovered outbox entry is marked sent instead of re-dispatched? That question is answered per adapter by reconciles_unknown_send. It is not a silent global default: at-least-once vs effectively-once is a per-channel fact.

An adapter opts in only when it both declares reconciles_unknown_send=True and exposes an async was_delivered(idempotency_key) -> bool. The durable outbox auto-wires the reconciler from these two signals (DurableDelivery._build_reconciler); when either is absent, recovered entries fall back to at-least-once re-send.

Adapterreconciles_unknown_sendReconciliation behaviour
TelegramFalseAt-least-once — the Telegram Bot API cannot confirm a client-side-keyed send.
DiscordFalseAt-least-once — no client-keyed delivery lookup.
SlackFalseAt-least-once — inherits the PlatformCapabilities() default.
Webhook / HTTPFalseAt-least-once by design.

Labelling the at-least-once fallback

For channels that fall back to at-least-once re-send, mark_recovered=True prefixes each crash-recovered copy with a visible marker so the recipient knows it may be a duplicate.

Diagram

mark_recovered=False

mark_recovered=True

💥 gateway restart mid-send

💾 recovered outbox entry

📤 re-send silently

♻️ prefix + re-send

The recipient sees this prefix on a labelled re-send:

♻️ Recovered reply — the gateway restarted during delivery, so this may be a duplicate.

Enable it once at setup:

python
setup_durable_delivery(
    outbox_path="~/.praisonai/state/gateway_outbox.sqlite",
    platform="telegram",
    mark_recovered=True,   # opt-in: label crash-recovered re-sends
)

What gets labelled

  • Only the unreconciled recovered branch — fresh sends and reconciled sends never carry the prefix.
  • Only string content payloads — media and structured payloads pass through unchanged.
  • Idempotent — a re-drained copy that already carries the prefix is not double-prefixed.
  • Sticky — a transient failure of a labelled re-send keeps the entry in recovered so the next retry is still labelled.
  • Fail-open — if labelling errors, the original unlabelled payload is sent (logged at WARNING); the send never fails because of it.

When to enable

  • Adapters where reconciles_unknown_send=False in the table above.
  • Product surfaces where honest “this may be a duplicate” is preferable to a silent duplicate.

Default

mark_recovered defaults to False — behaviour is unchanged unless you explicitly set it. See Durable Delivery for the full outbox reference.

How markdown_dialect is consumed

Every adapter that inherits BasePlatformAdapter.format_message() gets platform-correct rendering just by declaring markdown_dialect — the base class calls format_for_dialect(text, caps.markdown_dialect) for you.

Diagram

Agent reply → platform-correct rendering

gates

telegram_markdown_v2

slack

discord_markdown

markdown / unknown

📝 Agent reply
markdown

🔀 format_for_dialect

⚙️ markdown_dialect
capability

📱 MarkdownV2
Telegram

💬 mrkdwn
Slack

🎮 CommonMark
Discord

📄 Plain text
fallback

✅ User sees reply

format_for_dialect(text, dialect) returns a (rendered_text, parse_mode) pair. The parse_mode is the value a transport expects (Telegram needs "MarkdownV2"); it is None when the text is already in the platform’s native form.

markdown_dialect valueRendered asReturned parse_mode
"telegram_markdown_v2"MarkdownV2-escaped text (via escape_markdown_v2)"MarkdownV2"
"slack"Slack mrkdwn (via markdown_to_slack)None
"discord_markdown"Passthrough — Discord speaks CommonMarkNone
"markdown" / unknownSafe plain text (via strip_markdown)None

All four helpers are importable from praisonaiagents.bots:

python
from praisonaiagents.bots import (
    format_for_dialect,
    escape_markdown_v2,
    markdown_to_slack,
    strip_markdown,
)

An agent replies in ordinary markdown; declaring telegram_markdown_v2 makes special characters escape automatically instead of being dropped:

python
from praisonaiagents import Agent
from praisonaiagents.bots import PlatformCapabilities

agent = Agent(
    name="assistant",
    instructions="Explain what `svc_1` does and show `*.py` globs.",
)
# Any adapter declaring telegram_markdown_v2 now escapes automatically.
caps = PlatformCapabilities(markdown_dialect="telegram_markdown_v2")

Webhook-based platforms

Platforms that set accepts_webhooks=True must also expose a webhook_verifier so enforce_webhook_verification can enforce signatures fail-closed. See Webhook Verification.

Built-in platform defaults

PlatformNotes
Telegrammax_message_length=4096, length_unit="utf16", supports_edit=True, supports_delete=False, markdown_dialect="telegram_markdown_v2", needs_rate_limit=True, edit_interval_ms=1000, max_file_size_mb=50
Discordmax_message_length=2000, length_unit="codepoints", supports_edit=True, supports_delete=False, needs_rate_limit=False, edit_interval_ms=500, max_files_per_message=10, max_file_size_mb=8
Localsupports_edit=False, supports_delete=False, supports_typing=False, needs_rate_limit=False, accepts_webhooks=False, supports_media=False — a TTY-honest declaration (text-only, pull-based, no rate limit). See Local (Terminal) Channel.
slack, whatsapp, linear, email, agentmailUses PlatformCapabilities() defaults (supports_delete=False) until the adapter declares its own

No built-in adapter declares supports_delete=True, so none trips the capability contract.

Native / command menu

Some platforms publish the bot’s commands to their native / menu so typing / shows autocomplete. Adapters override publish_command_menu to project the shared CommandRegistry; the base implementation is a no-op.

PlatformNative / menuAPI used
Telegram✅set_my_commands
Discord✅CommandTree.sync()
Slack, WhatsApp, others❌ (base no-op)future — override publish_command_menu

See Native / Autocomplete for user-facing behaviour and the Discord shim caveat.

Common patterns

Subclass with default_capabilities() (Telegram and Discord use this):

python
@classmethod
def default_capabilities(cls) -> PlatformCapabilities:
    return PlatformCapabilities(max_message_length=2000, supports_edit=True)

Entry-point registrations (via praisonai.channels) get default capabilities unless the adapter class exposes a default_capabilities() classmethod. This keeps zero-config connectors functional while letting polished adapters declare exact limits:

toml
# pyproject.toml
[project.entry-points."praisonai.channels"]
myplatform = "mypackage.bot:MyPlatformBot"
python
class MyPlatformBot:
    @classmethod
    def default_capabilities(cls):
        from praisonaiagents.bots import PlatformCapabilities
        return PlatformCapabilities(max_message_length=1000, supports_edit=False)

    async def start(self): ...
    async def stop(self): ...

Serialise for config files:

python
caps = get_platform_capabilities("telegram")
data = caps.to_dict()
restored = PlatformCapabilities.from_dict(data)

Best Practices

Use utf16 for Telegram

Telegram counts UTF-16 code units. Wrong length_unit can silently truncate messages.

Set needs_rate_limit=False only when the SDK rate-limits

Discord.py handles limits internally; raw Telegram HTTP does not.

Enable supports_edit only with edit_message()

UnifiedDelivery streams via edits when this flag is true.

Prefer default_capabilities() on the adapter class

Keeps registry caching consistent when platforms override defaults.

Related