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.
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.
Quick Start
- 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) - 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.
Configuration options
| Field | Type | Default | Description |
|---|---|---|---|
max_message_length | int | 4096 | Maximum message length in the platform’s unit |
length_unit | str | "codepoints" | "codepoints" or "utf16" |
supports_edit | bool | False | In-place message edits (streaming) |
supports_typing | bool | True | Typing indicators |
supports_delete | bool | False | Whether 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_dialect | str | "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_limit | bool | True | Apply Praison rate limiting |
edit_interval_ms | int | 1000 | Minimum ms between edits |
max_files_per_message | int | 1 | Attachments per message |
max_file_size_mb | int | 10 | Max file size in MB |
supported_file_types | List[str] | ["*"] | Allowed mime types or extensions |
accepts_webhooks | bool | False | Channel receives inbound HTTP webhooks |
verifies_webhook_signature | bool | False | Adapter exposes a webhook verifier |
reconciles_unknown_send | bool | False | Adapter can confirm whether a prior send actually landed (via was_delivered(idempotency_key)). Enables effectively-once delivery via the durable outbox. |
supports_idempotency_token | bool | False | Informational — transport accepts a provider-level idempotency token. Set reconciles_unknown_send as well if you need effectively-once. |
supports_status_text | bool | False | Whether 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:
from praisonai.bots._chunk import _calculate_length
reply = "😀" * 200
print(_calculate_length(reply, "codepoints")) # 200
print(_calculate_length(reply, "utf16")) # 400So 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).
from praisonai.bots._registry import get_platform_capabilities
caps = get_platform_capabilities("telegram")
print(caps.length_unit) # utf16Delivery 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.
| Adapter | reconciles_unknown_send | Reconciliation behaviour |
|---|---|---|
| Telegram | False | At-least-once — the Telegram Bot API cannot confirm a client-side-keyed send. |
| Discord | False | At-least-once — no client-keyed delivery lookup. |
| Slack | False | At-least-once — inherits the PlatformCapabilities() default. |
| Webhook / HTTP | False | At-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.
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:
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
recoveredbranch — fresh sends and reconciled sends never carry the prefix. - Only string
contentpayloads — 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
recoveredso 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=Falsein 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.
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 value | Rendered as | Returned 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 CommonMark | None |
"markdown" / unknown | Safe plain text (via strip_markdown) | None |
All four helpers are importable from praisonaiagents.bots:
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:
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
| Platform | Notes |
|---|---|
| Telegram | max_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 |
| Discord | max_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 |
| Local | supports_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, agentmail | Uses 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.
| Platform | Native / menu | API 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):
@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:
# pyproject.toml
[project.entry-points."praisonai.channels"]
myplatform = "mypackage.bot:MyPlatformBot"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:
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
Effectively-once delivery via crash reconciliation
Display PolicyOperator policy for streaming and footers
Bot Platform AdapterBuild a channel — the format_message / markdown_dialect seam
Declared supports_* flags are verified at build time so unbacked flags fail loudly
Register custom adapters
Bot Streaming RepliesUses supports_edit and edit_interval_ms
Bot Rate LimitingUses needs_rate_limit
Chunking StrategiesUses max_message_length and length_unit
Reply DeliveryHow length_unit drives chunking so replies reach the user intact