Bot Platform Plugins

Add custom messaging-platform bots to PraisonAI via Python entry points

python
from praisonaiagents import Agent

agent = Agent(name="platform-bot", instructions="Use platform-specific bot plugins.")
agent.start("Enable Telegram and Discord plugins for this bot.")

Third-party bot packages can register via Python entry points to extend PraisonAI with custom messaging platforms.

The user installs a third-party bot package; entry points register new platforms alongside built-in channels.

Diagram

📦 Entry Point
praisonai.channels

📝 BotPlatformRegistry

📄 YAML adapter: ref

📁 .praisonai/channels/*.py

💬 Bot – mattermost – agent=…

Five registration paths reach the same adapter class: three that need Python (built-ins, entry point, register_platform()) and two zero-code paths (YAML adapter: ref and .praisonai/channels/ drop-in files). See Custom Gateway Channel (Zero-Code) for the full zero-code story.

Quick Start

  1. Programmatic Registration

    Register a bot platform directly in your code:

    python
    from praisonai.bots._registry import register_platform
    
    class MyBot:
        def __init__(self, **kwargs):
            self.kwargs = kwargs
    
        async def start(self):
            print("Starting MyBot...")
    
        async def stop(self):
            print("Stopping MyBot...")
    
    register_platform("mybot", MyBot)
  2. Entry-point Plugin

    Create a pip-installable plugin using pyproject.toml:

    toml
    # pyproject.toml
    [project.entry-points."praisonai.channels"]
    mybot = "my_pkg.bot:MyBot"

    After installation, use the bot platform:

    python
    from praisonai.bots import Bot
    
    bot = Bot("mybot", agent=my_agent)
    await bot.start()

Zero-Code Paths

Two paths register a channel with no packaging and no bootstrap Python — both self-register the adapter before startup.

YAML adapter: import ref. Point a channel at a dotted "module:Class" string; the gateway imports, validates, and registers it.

yaml
# gateway.yaml
channels:
  my_intranet_chat:
    adapter: "my_company.adapters:IntranetBot"
    token: ${INTRANET_TOKEN}

Filesystem drop-in. Any BasePlatformAdapter subclass in ./.praisonai/channels/.py (project, gated by PRAISONAI_ALLOW_PROJECT_PLUGINS=true) or ~/.praisonai/channels/.py (user-global, trusted) registers under its platform_name.

bash
mkdir -p ~/.praisonai/channels
cp adapters.py ~/.praisonai/channels/
praisonai gateway start

See Custom Gateway Channel (Zero-Code) for the trust model, error surface, and copy-paste recipes.


How It Works

Diagram
PluginRegistryBotUserPluginRegistryBotUserBot(“mybot”, agent=…)resolve_adapter(“mybot”)lazy load via entry pointMyBot classMyBotbot instance

The bot platform registry provides a central point for managing bot implementations:

OperationDescriptionWhen Called
DiscoveryEntry points auto-loaded on registry accessImport time
RegistrationBot platforms registered by namePlugin installation
CreationBot instances created on demandBot() constructor
AvailabilityPlatform dependencies checkedBefore execution

Configuration

The bot platform registry supports both programmatic and entry-point registration:

FunctionPurpose
register_platform(name, cls, capabilities=None)Register at runtime (optional PlatformCapabilities descriptor)
get_platform_capabilities(name)Get the capabilities descriptor for a registered platform
list_platforms()List all registered platform names
resolve_adapter(name)Get class for a platform name
get_platform_registry()Backward-compat: returns dict[name, class] of all platforms
get_default_bot_registry()Get the process-default BotPlatformRegistry (advanced)

Built-in Platforms

PraisonAI includes these built-in bot platforms:

  • telegram – Telegram bot integration
  • discord – Discord bot integration
  • slack – Slack bot integration
  • whatsapp – WhatsApp bot integration
  • linear – Linear issues integration
  • email – Email bot integration
  • agentmail – AgentMail integration
  • webhook – Generic HTTP webhook trigger (declarative routes) — see Webhook Channel

Entry-point Groups

GroupPurpose
praisonai.channelsRecommended for new packaged connectors — idiomatic, zero-config auto-registration
praisonai.botsLegacy group — still scanned for backward compatibility

Both groups are scanned by BotPlatformRegistry on startup. A connector that would shadow a built-in platform is skipped with a warning.

Discovery via entry points (praisonai.channels)

The praisonai.channels entry-point group is the idiomatic way to distribute a bot connector as a pip-installable package. Once installed, the platform is available with no extra Python code:

toml
# pyproject.toml of your connector package
[project.entry-points."praisonai.channels"]
irc = "praisonai_irc:IRCBot"

After pip install praisonai-irc:

python
from praisonai.bots import Bot

bot = Bot("irc", agent=my_agent, server="irc.libera.chat")
await bot.start()

List all registered platforms — built-in, entry-point, and custom — with the CLI:

bash
praisonai gateway channels --available
# => telegram, discord, slack, whatsapp, linear, email, agentmail, irc

Or in Python:

python
from praisonai.bots._registry import list_platforms

print(sorted(list_platforms()))

list_platforms() also returns anything registered by register_platform() in the current process. The entry-point group is loaded lazily on first registry access, so a plugin that is installed but never imported still appears after the first resolve_adapter() call.

Plugin channel on the gateway runtime

Register a plugin platform, then reference it in gateway.yaml:

python
# my_plugin.py
from praisonai.bots._registry import register_platform

class MattermostBot:
    def __init__(self, **kwargs): self.cfg = kwargs
    async def start(self): ...
    async def stop(self): ...

register_platform("mattermost", MattermostBot)
yaml
# gateway.yaml
agents:
  assistant:
    instructions: "You are a helpful assistant."
channels:
  mattermost:
    token: ${MATTERMOST_TOKEN}
bash
python -c "import my_plugin" && praisonai gateway start --config gateway.yaml
# Before PR #3579: gateway silently skipped 'mattermost' with a single WARNING; channel never started.
# After PR #3579:  resolve_adapter('mattermost') succeeds, adapter is constructed, channel runs;
#                  an unresolvable name is recorded as a degraded channel instead of dropped.

The gateway walks the same path a built-in channel does:

Diagram
sequenceDiagram
    participant User
    participant Gateway as WebSocketGateway
    participant Registry as resolve_adapter
    participant Adapter as PluginBot

    User->>Gateway: praisonai gateway start --config gateway.yaml
    Gateway->>Gateway: _create_bot(channel_type, ...)
    Gateway->>Gateway: _build_channel_adapter(...)
    Gateway->>Registry: resolve_adapter("mattermost")
    Registry-->>Gateway: PluginBot class (register_platform or entry point)
    Gateway->>Gateway: seed kwargs + _EXTRA_ENV_MAP backfill + ch_cfg pass-through
    Gateway->>Adapter: PluginBot(**init_kwargs)
    Adapter-->>Gateway: instance
    Gateway->>Adapter: await bot.start()

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef result fill:#10B981,stroke:#7C90A0,color:#fff

    class User,Adapter agent
    class Gateway,Registry process

When resolution fails, the gateway calls self._mark_degraded_owner("channel", "mattermost", reason="unresolved_platform") instead of raising — the channel is skipped but stays visible in health / doctor.

How the gateway constructs a plugin channel

The gateway builds every channel adapter through one resolution seam and a three-step kwarg assembly.

Adapter resolution. resolve_adapter(channel_type) is the single lookup, resolved in ladder order: a registered platform (built-in / entry point / register_platform) wins, then a YAML adapter: import ref, then a .praisonai/channels/ drop-in file. Only when none resolve does the gateway mark the channel unresolved_platform.

Kwarg assembly. The gateway layers three sources in order; later sources override earlier ones:

OrderSourceContents
1Seed`{“token”: <token>, “agent”:

, “config”: <config>} | | 2 | _EXTRA_ENV_MAP[channel_type] | For each param → env_key, if os.environ.get(env_key) is non-empty, set init_kwargs[param] = env_val (e.g. Slack's SLACK_APP_TOKEN → app_token) | | 3 | ch_cfg (from channels: YAML) | Every key except “platform” and “token”` copied verbatim; overrides step 2 |

The gateway then calls adapter_cls(**init_kwargs). ch_cfg wins over env backfill, and platform / token are stripped so they never double up. This mirrors probe_channels and Bot._build_adapter.

Token env-var fallback. When the token from channels: YAML is falsy, the gateway consults _TOKEN_FALLBACK_ENV — the first non-empty value wins:

channel_typeEnv vars tried in order
linearLINEAR_OAUTH_TOKEN, LINEAR_API_KEY
emailEMAIL_APP_PASSWORD
agentmailAGENTMAIL_API_KEY

Other platforms have no token fallback here — Slack’s app-token env still comes from step 2 (_EXTRA_ENV_MAP), not this map.

Where a plugin author sets each value:

Diagram

channels: YAML — highest wins

_EXTRA_ENV_MAP env var backfill

constructor default in the adapter class — lowest

Degraded channels: what happens when a plugin fails

A channel that cannot resolve or construct is recorded as degraded, not silently dropped.

Two exact reason strings surface via _mark_degraded_owner("channel", <channel_type>, reason=...):

reasonTriggerLog line
unresolved_platformresolve_adapter(channel_type) raised ValueError — no built-in, register_platform, entry-point, YAML adapter: ref, or .praisonai/channels/ drop-in resolves itUnknown channel type %r — no built-in, registered, or entry-point adapter resolves it; channel not started.
adapter_construction_failedadapter_cls(**init_kwargs) raised any Exception (e.g. wrong kwargs, connection error)Failed to construct channel %r adapter: %s

Both paths return None from _create_bot, so the two call sites (start_channels and hot-reload) keep their existing None contract. The failure is queryable via the degraded-owner surface (health / doctor) — see Gateway Degraded Channels — instead of vanishing into logs.


Common Patterns

Declare Platform Capabilities

python
from praisonaiagents.bots import PlatformCapabilities
from praisonai.bots._registry import register_platform

register_platform(
    "mybot",
    MyBot,
    capabilities=PlatformCapabilities(
        max_message_length=2000,
        length_unit="codepoints",
        supports_edit=True,
        markdown_dialect="markdown",
    ),
)

Capabilities let PraisonAI’s shared delivery layer chunk, stream, and rate-limit messages correctly for your platform — see Bot Platform Capabilities for the full field list.

Register a Native Presentation Renderer

Channel plugins that ship a PresentationRenderer should call register_presentation_renderer(platform, MyRenderer) from praisonaiagents.bots at setup time, so interactive UI (buttons, selects, approvals) renders natively instead of degrading to plain text.

python
from praisonaiagents.bots import register_presentation_renderer
from my_matrix_plugin.renderer import MatrixPresentationRenderer

register_presentation_renderer("matrix", MatrixPresentationRenderer)

This is the same extension point built-in channels use. See Presentation Renderers for the renderer contract and validation rules.

Use the Canonical Admission Primitive

Reuse resolve_ingress_admission() in your adapter’s message handler so your custom channel inherits the same allowlist / blocklist / group-policy semantics as every built-in — and drops become inspectable (reason_code) instead of silent logger.debug lines.

python
from praisonaiagents.bots import resolve_ingress_admission

class MyBot:
    async def on_message(self, msg):
        decision = resolve_ingress_admission(
            chat_type=msg.chat_type,            # "dm" / "private" / "group" / "channel" / ...
            sender_id=msg.sender_id,
            is_mention=msg.mentions_bot,
            is_command=msg.text.startswith("/"),
            allowlist=self.config.allowed_users,
            blocklist=self.config.blocked_users,
            group_policy=self.config.group_policy,
            paired=self.pairing.is_paired(msg.sender_id),
        )

        if not decision.admit:
            # Record the reason so operators can answer "why didn't the bot reply?"
            logger.info(
                "message dropped: gate=%s reason=%s sender=%s",
                decision.gate, decision.reason_code, msg.sender_id,
            )
            if decision.observe:
                # `observe` group policy: passively record but do not run the agent
                await self.session.record_passive(msg)
            return

        await self.agent.run(msg.text)

The ladder (first matching gate wins): block-list → allow-list → pairing → direct-chat bypass → group policy. An unset group_policy defaults to mention_only — the live BotConfig default — so a forwarded unset policy fails safe rather than replying to everything in a group.

Adapters compute structured MentionFacts (explicit, reply_to_bot, quoted_bot, thread_participant) and hand them to resolve_ingress_admission(mention=…). Under mention_only / observe, a reply to the bot is admitted as an implicit mention (see Implicit Mentions). The primitive is back-compatible with the old is_mention: bool argument, so existing adapters keep working unchanged.

Diagram

Yes

No

Yes

No

No

Yes

Yes

No

respond_all

mention_only
+ reply to bot

mention_only
+ !mention/cmd/reply

command_only
+ !cmd

observe
+ !mention/cmd

📨 Inbound message

Block-listed?

🛑 blocked

Allow-list
configured & missing?

🛑 not_in_allowlist

Paired?

🛑 pairing_required

Direct chat?

✅ allowed (direct)

group_policy

✅ allowed

✅ allowed (implicit)

🛑 group_mention_only

🛑 command_only

👁 observe (passive)

Reason codes an operator can grep for: allowed, blocked, not_in_allowlist, pairing_required, group_mention_only, command_only, observe.

IngressDecision fields:

FieldTypeMeaning
admitboolWhether the adapter should run the agent on this message.
reason_codestrMachine-readable reason (one of the seven above).
gatestrWhich stage decided: blocklist, allowlist, pairing, direct, group_policy.
observeboolWhen True, do not admit but do record the message as passive session context (observe group policy).

Override a Built-in Platform

Registry uses last-write-wins with lower-cased keys:

python
from praisonai.bots._registry import register_platform

class CustomSlackBot:
    def __init__(self, **kwargs):
        self.token = kwargs.get('token')

    async def start(self):
        # Custom Slack implementation
        pass

    async def stop(self):
        pass

# Override built-in Slack bot
register_platform("slack", CustomSlackBot)

Lazy Heavy Imports

Follow the pattern used by built-ins to avoid import-time failures:

python
class HeavyFrameworkBot:
    def __init__(self, **kwargs):
        self.config = kwargs
        self._client = None

    async def start(self):
        # Only import when actually starting
        import heavy_networking_sdk
        self._client = heavy_networking_sdk.Client(
            token=self.config.get('token')
        )
        await self._client.connect()

    async def stop(self):
        if self._client:
            await self._client.disconnect()

Multi-tenant Isolation

Construct your own BotPlatformRegistry to avoid leaking between tenants:

python
from praisonai.bots._registry import BotPlatformRegistry

# Each tenant gets their own registry
tenant_registry = BotPlatformRegistry()
tenant_registry.register("custom-slack", TenantSpecificSlackBot)

Best Practices

Use Lazy Imports

Never import heavy networking SDKs at module top level:

python
# ❌ Bad - imports at module level
import heavy_sdk

class BadBot:
    def __init__(self, **kwargs):
        self.client = heavy_sdk.Client()

# ✅ Good - lazy imports
class GoodBot:
    async def start(self):
        import heavy_sdk
        self.client = heavy_sdk.Client()
Implement Proper Protocol

Follow the expected bot lifecycle pattern:

python
class ProperBot:
    def __init__(self, **kwargs):
        # Store config, don't establish connections yet
        self.config = kwargs
        self.running = False

    async def start(self):
        # Establish connections, start listening
        self.running = True

    async def stop(self):
        # Clean shutdown
        self.running = False
Handle Errors Gracefully

Use logging instead of raising on initialization:

python
import logging
logger = logging.getLogger(__name__)

class RobustBot:
    def __init__(self, **kwargs):
        self.config = kwargs

    async def start(self):
        try:
            # Connection logic here
            pass
        except Exception as e:
            logger.error(f"Failed to start bot: {e}")
            # Don't re-raise, let caller handle

Delivery lifecycle

Bot plugin authors are the primary consumers of the enriched message identity — platform and channel_id on inbound messages, plus successful and failed delivery signals on outbound. Override message_sent and message_undelivered in a Plugin subclass to add delivery telemetry or dead-letter escalation without patching adapters.


Related