Skip to main content
This guide walks you through writing a new Hiro League channel plugin from scratch. A plugin connects a third-party messaging platform — Telegram, WhatsApp, your mobile app, or anything else — to the Hiro League server. For the architectural background — subprocess model, JSON-RPC protocol, and message flow — see Channel plugin architecture.

Prerequisites

  • hirocli set up and running (hirocli setup, hirocli start)
  • uv installed — docs.astral.sh/uv
  • Python ≥ 3.11

Quick start — clone the echo plugin

The fastest way to start is to copy the reference implementation:
hiroserver/
└── channels/
    └── hiro-channel-echo/        ← copy this entire directory
Rename it hiro-channel-<yourname>/ and follow the steps below.

1. Package structure

hiroserver/channels/hiro-channel-telegram/
├── pyproject.toml
└── src/
    └── hiro_channel_telegram/
        ├── __init__.py
        ├── plugin.py       ← your ChannelPlugin subclass
        └── main.py         ← CLI entry point

2. pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "hiro-channel-telegram"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "hiro-channel-sdk",
    "python-telegram-bot>=21",   # or whatever the third-party SDK is
    "typer>=0.12",
]

[project.scripts]
hiro-channel-telegram = "hiro_channel_telegram.main:app"

[tool.hatch.build.targets.wheel]
packages = ["src/hiro_channel_telegram"]

[tool.uv.sources]
hiro-channel-sdk = { workspace = true }
[tool.uv.sources] is required for uv to resolve hiro-channel-sdk from the local workspace instead of PyPI.

3. Implement ChannelPlugin

Open plugin.py and subclass hiro_channel_sdk.ChannelPlugin:
from __future__ import annotations

import asyncio
import logging
from typing import Any

from telegram import Update
from telegram.ext import Application, MessageHandler, filters

from hiro_channel_sdk import ChannelPlugin, ChannelInfo, UnifiedMessage

logger = logging.getLogger(__name__)


class TelegramChannel(ChannelPlugin):

    @property
    def info(self) -> ChannelInfo:
        return ChannelInfo(
            name="telegram",
            version="0.1.0",
            description="Telegram Bot channel via python-telegram-bot.",
        )

    async def on_configure(self, config: dict[str, Any]) -> None:
        """Receive credentials pushed by hirocli on connect."""
        self._bot_token = config["bot_token"]

    async def on_start(self) -> None:
        """Start the Telegram bot polling loop."""
        self._app = (
            Application.builder()
            .token(self._bot_token)
            .build()
        )

        async def _handle(update: Update, context) -> None:
            msg = update.message
            if not msg or not msg.text:
                return
            unified = UnifiedMessage(
                channel="telegram",
                direction="inbound",
                sender_id=str(msg.from_user.id),
                body=msg.text,
                metadata={
                    "chat_id": msg.chat.id,
                    "message_id": msg.message_id,
                    "username": msg.from_user.username,
                },
            )
            await self.emit(unified)  # forwards to hirocli

        self._app.add_handler(MessageHandler(filters.TEXT, _handle))
        asyncio.create_task(self._app.run_polling())
        logger.info("Telegram bot started.")

    async def on_stop(self) -> None:
        """Shut down the bot."""
        if self._app:
            await self._app.stop()
        logger.info("Telegram bot stopped.")

    async def send(self, message: UnifiedMessage) -> None:
        """Send an outbound message to Telegram."""
        chat_id = message.metadata.get("chat_id") or message.recipient_id
        if not chat_id:
            logger.warning("No chat_id for outbound Telegram message.")
            return
        await self._app.bot.send_message(chat_id=chat_id, text=message.body)

Methods you must implement

MethodCalled whenWhat to do
info (property)registrationReturn ChannelInfo(name=..., version=..., description=...)
on_configure(config)hirocli pushes credentialsStore API keys, tokens, etc.
on_start()plugin connects to hirocliStart polling / set up webhooks
on_stop()hirocli sends stop signalCancel tasks, close connections
send(message)hirocli wants to send a messageTranslate UnifiedMessage → third-party API call

The emit method

await self.emit(unified_message) is inherited — call it whenever an inbound message arrives from the third party. The transport layer forwards it to hirocli as a channel.receive JSON-RPC notification automatically.

4. Write main.py

import asyncio
import logging
import sys

import typer
from hiro_channel_sdk import PluginTransport
from .plugin import TelegramChannel

app = typer.Typer(name="hiro-channel-telegram", add_completion=False)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)


@app.command()
def run(
    hiro_ws: str = typer.Option(
        "ws://127.0.0.1:18081",
        "--hiro-ws",
        envvar="HIRO_WS",
        help="WebSocket URL of hirocli's plugin server.",
    ),
) -> None:
    """Connect to hirocli and start the Telegram channel."""
    plugin = TelegramChannel()
    transport = PluginTransport(plugin, hiro_ws)
    asyncio.run(transport.run())


if __name__ == "__main__":
    app()
The --hiro-ws argument is automatically appended by ChannelManager when spawning the subprocess. The envvar="HIRO_WS" allows overriding it in development without CLI args.

5. Install and configure

Add the channel to the uv workspace and install it in-place:
# The workspace root already has channels/* as a member, so just sync:
cd hiroserver
uv sync

# Register with hirocli:
hirocli channel setup telegram
# Prompts: "Command to start the 'telegram' plugin" → hiro-channel-telegram

Store credentials

Edit ~/.hirocli/channels/telegram.json and add your credentials to the config field:
{
  "name": "telegram",
  "enabled": true,
  "command": ["hiro-channel-telegram"],
  "config": {
    "bot_token": "123456:ABC-DEF..."
  }
}
hirocli pushes this config dict to your plugin via on_configure() automatically each time the plugin connects.

6. Run and verify

# Restart the server so ChannelManager spawns your new channel:
hirocli stop
hirocli start

# Check that it connected:
hirocli channel status
Expected output:
         Connected channels
 Name      Version  Description
 telegram  0.1.0    Telegram Bot channel…

7. Sending events back to hirocli

Beyond inbound messages you can emit structured events using channel.event for delivery receipts, errors, or status changes. Currently, log them via the standard logger:
logger.warning("Delivery failed for message %s", message.id)
Full channel.event support — arbitrary structured events passed upstream to hirocli handlers — is on the roadmap.

Reference

UnifiedMessage fields

FieldTypeRequiredNotes
idstrautoUUID hex, auto-generated
channelstryesPlugin name, e.g. "telegram"
directionstryes"inbound" or "outbound"
sender_idstryesChannel-native user identifier
recipient_idstr | NonenoChannel-native target identifier
content_typestrdefault "text""text" | "image" | "audio" | "video" | "location" | "command" | "file"
bodystrdefault ""Text content or caption
metadatadictdefault {}Channel-specific extras — free-form
timestampdatetimeautoUTC, auto-generated

hirocli channel commands

CommandDescription
hirocli channel listList all configured plugins and their status
hirocli channel install <name>Install via uv tool install hiro-channel-<name>
hirocli channel setup <name>Configure command and credentials interactively
hirocli channel enable <name>Enable a disabled plugin
hirocli channel disable <name>Disable without removing config
hirocli channel remove <name>Delete the plugin’s config file
hirocli channel statusShow currently connected plugins (live query)

Troubleshooting

  • Check the subprocess is running: hirocli status
  • Check the plugin logs (stdout/stderr of the subprocess)
  • Verify the command in ~/.hirocli/channels/<name>.json is on PATH
  • Try running the plugin manually: hiro-channel-telegram --hiro-ws ws://127.0.0.1:18081
  • Make sure the config key in the JSON file is populated (not {})
  • Config is pushed immediately after channel.register — check your logs
Use the echo channel: hirocli channel setup echo — it reflects any outbound message back as inbound, letting you verify the full round-trip pipeline.

See also