| 1 | # Channel Plugin Guide |
| 2 | |
| 3 | Build a custom nanobot channel in three steps: subclass, package, install. |
| 4 | |
| 5 | > **Note:** We recommend developing channel plugins against a source checkout of nanobot (`pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs. |
| 6 | |
| 7 | ## How It Works |
| 8 | |
| 9 | nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans: |
| 10 | |
| 11 | 1. Built-in channels in `nanobot/channels/` |
| 12 | 2. External packages registered under the `nanobot.channels` entry point group |
| 13 | |
| 14 | If a matching config section has `"enabled": true`, the channel is instantiated and started. |
| 15 | |
| 16 | ## Quick Start |
| 17 | |
| 18 | We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back. |
| 19 | |
| 20 | ### Project Structure |
| 21 | |
| 22 | ```text |
| 23 | nanobot-channel-webhook/ |
| 24 | ├── nanobot_channel_webhook/ |
| 25 | │ ├── __init__.py # re-export WebhookChannel |
| 26 | │ └── channel.py # channel implementation |
| 27 | └── pyproject.toml |
| 28 | ``` |
| 29 | |
| 30 | ### 1. Create Your Channel |
| 31 | |
| 32 | ```python |
| 33 | # nanobot_channel_webhook/__init__.py |
| 34 | from nanobot_channel_webhook.channel import WebhookChannel |
| 35 | |
| 36 | __all__ = ["WebhookChannel"] |
| 37 | ``` |
| 38 | |
| 39 | ```python |
| 40 | # nanobot_channel_webhook/channel.py |
| 41 | import asyncio |
| 42 | from typing import Any |
| 43 | |
| 44 | from aiohttp import web |
| 45 | from loguru import logger |
| 46 | from pydantic import Field |
| 47 | |
| 48 | from nanobot.channels.base import BaseChannel |
| 49 | from nanobot.bus.events import OutboundMessage |
| 50 | from nanobot.bus.queue import MessageBus |
| 51 | from nanobot.config.schema import Base |
| 52 | |
| 53 | |
| 54 | class WebhookConfig(Base): |
| 55 | """Webhook channel configuration.""" |
| 56 | enabled: bool = False |
| 57 | port: int = 9000 |
| 58 | allow_from: list[str] = Field(default_factory=list) |
| 59 | |
| 60 | |
| 61 | class WebhookChannel(BaseChannel): |
| 62 | name = "webhook" |
| 63 | display_name = "Webhook" |
| 64 | |
| 65 | def __init__(self, config: Any, bus: MessageBus): |
| 66 | if isinstance(config, dict): |
| 67 | config = WebhookConfig(**config) |
| 68 | super().__init__(config, bus) |
| 69 | |
| 70 | @classmethod |
| 71 | def default_config(cls) -> dict[str, Any]: |
| 72 | return WebhookConfig().model_dump(by_alias=True) |
| 73 | |
| 74 | async def start(self) -> None: |
| 75 | """Start an HTTP server that listens for incoming messages. |
| 76 | |
| 77 | IMPORTANT: start() must block forever (or until stop() is called). |
| 78 | If it returns, the channel is considered dead. |
| 79 | """ |
| 80 | self._running = True |
| 81 | port = self.config.port |
| 82 | |
| 83 | app = web.Application() |
| 84 | app.router.add_post("/message", self._on_request) |
| 85 | runner = web.AppRunner(app) |
| 86 | await runner.setup() |
| 87 | site = web.TCPSite(runner, "0.0.0.0", port) |
| 88 | await site.start() |
| 89 | logger.info("Webhook listening on :{}", port) |
| 90 | |
| 91 | # Block until stopped |
| 92 | while self._running: |
| 93 | await asyncio.sleep(1) |
| 94 | |
| 95 | await runner.cleanup() |
| 96 | |
| 97 | async def stop(self) -> None: |
| 98 | self._running = False |
| 99 | |
| 100 | async def send(self, msg: OutboundMessage) -> None: |
| 101 | """Deliver an outbound message. |
| 102 | |
| 103 | msg.content — markdown text (convert to platform format as needed) |
| 104 | msg.media — list of local file paths to attach |
| 105 | msg.chat_id — the recipient (same chat_id you passed to _handle_message) |
| 106 | msg.metadata — may contain "_progress": True for streaming chunks |
| 107 | """ |
| 108 | logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80]) |
| 109 | # In a real plugin: POST to a callback URL, send via SDK, etc. |
| 110 | |
| 111 | async def _on_request(self, request: web.Request) -> web.Response: |
| 112 | """Handle an incoming HTTP POST.""" |
| 113 | body = await request.json() |
| 114 | sender = body.get("sender", "unknown") |
| 115 | chat_id = body.get("chat_id", sender) |
| 116 | text = body.get("text", "") |
| 117 | media = body.get("media", []) # list of URLs |
| 118 | |
| 119 | # This is the key call: validates allowFrom, then puts the |
| 120 | # message onto the bus for the agent to process. |
| 121 | await self._handle_message( |
| 122 | sender_id=sender, |
| 123 | chat_id=chat_id, |
| 124 | content=text, |
| 125 | media=media, |
| 126 | ) |
| 127 | |
| 128 | return web.json_response({"ok": True}) |
| 129 | ``` |
| 130 | |
| 131 | ### 2. Register the Entry Point |
| 132 | |
| 133 | ```toml |
| 134 | # pyproject.toml |
| 135 | [project] |
| 136 | name = "nanobot-channel-webhook" |
| 137 | version = "0.1.0" |
| 138 | dependencies = ["echo-director-agent", "aiohttp"] |
| 139 | |
| 140 | [project.entry-points."nanobot.channels"] |
| 141 | webhook = "nanobot_channel_webhook:WebhookChannel" |
| 142 | |
| 143 | [build-system] |
| 144 | requires = ["hatchling"] |
| 145 | build-backend = "hatchling.build" |
| 146 | |
| 147 | [tool.hatch.build.targets.wheel] |
| 148 | packages = ["nanobot_channel_webhook"] |
| 149 | ``` |
| 150 | |
| 151 | The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass. |
| 152 | |
| 153 | ### 3. Install & Configure |
| 154 | |
| 155 | ```bash |
| 156 | pip install -e . |
| 157 | nanobot plugins list # verify "Webhook" shows as "plugin" |
| 158 | nanobot onboard # auto-adds default config for detected plugins |
| 159 | ``` |
| 160 | |
| 161 | Edit `~/.nanobot/config.json`: |
| 162 | |
| 163 | ```json |
| 164 | { |
| 165 | "channels": { |
| 166 | "webhook": { |
| 167 | "enabled": true, |
| 168 | "port": 9000, |
| 169 | "allowFrom": ["*"] |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | ``` |
| 174 | |
| 175 | ### 4. Run & Test |
| 176 | |
| 177 | ```bash |
| 178 | nanobot gateway |
| 179 | ``` |
| 180 | |
| 181 | In another terminal: |
| 182 | |
| 183 | ```bash |
| 184 | curl -X POST http://localhost:9000/message \ |
| 185 | -H "Content-Type: application/json" \ |
| 186 | -d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}' |
| 187 | ``` |
| 188 | |
| 189 | The agent receives the message and processes it. Replies arrive in your `send()` method. |
| 190 | |
| 191 | ## BaseChannel API |
| 192 | |
| 193 | ### Required (abstract) |
| 194 | |
| 195 | | Method | Description | |
| 196 | |--------|-------------| |
| 197 | | `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. | |
| 198 | | `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. | |
| 199 | | `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. | |
| 200 | |
| 201 | ### Interactive Login |
| 202 | |
| 203 | If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`: |
| 204 | |
| 205 | ```python |
| 206 | async def login(self, force: bool = False) -> bool: |
| 207 | """ |
| 208 | Perform channel-specific interactive login. |
| 209 | |
| 210 | Args: |
| 211 | force: If True, ignore existing credentials and re-authenticate. |
| 212 | |
| 213 | Returns True if already authenticated or login succeeds. |
| 214 | """ |
| 215 | # For QR-code-based login: |
| 216 | # 1. If force, clear saved credentials |
| 217 | # 2. Check if already authenticated (load from disk/state) |
| 218 | # 3. If not, show QR code and poll for confirmation |
| 219 | # 4. Save token on success |
| 220 | ``` |
| 221 | |
| 222 | Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`. |
| 223 | |
| 224 | Users trigger interactive login via: |
| 225 | ```bash |
| 226 | nanobot channels login <channel_name> |
| 227 | nanobot channels login <channel_name> --force # re-authenticate |
| 228 | ``` |
| 229 | |
| 230 | ### Provided by Base |
| 231 | |
| 232 | | Method / Property | Description | |
| 233 | |-------------------|-------------| |
| 234 | | `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. | |
| 235 | | `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. | |
| 236 | | `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. | |
| 237 | | `transcribe_audio(file_path)` | Transcribes audio via Groq Whisper (if configured). | |
| 238 | | `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. | |
| 239 | | `is_running` | Returns `self._running`. | |
| 240 | | `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. | |
| 241 | |
| 242 | ### Optional (streaming) |
| 243 | |
| 244 | | Method | Description | |
| 245 | |--------|-------------| |
| 246 | | `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. | |
| 247 | |
| 248 | ### Message Types |
| 249 | |
| 250 | ```python |
| 251 | @dataclass |
| 252 | class OutboundMessage: |
| 253 | channel: str # your channel name |
| 254 | chat_id: str # recipient (same value you passed to _handle_message) |
| 255 | content: str # markdown text — convert to platform format as needed |
| 256 | media: list[str] # local file paths to attach (images, audio, docs) |
| 257 | metadata: dict # may contain: "_progress" (bool) for streaming chunks, |
| 258 | # "message_id" for reply threading |
| 259 | ``` |
| 260 | |
| 261 | ## Streaming Support |
| 262 | |
| 263 | Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it. |
| 264 | |
| 265 | ### How It Works |
| 266 | |
| 267 | When **both** conditions are met, the agent streams content through your channel: |
| 268 | |
| 269 | 1. Config has `"streaming": true` |
| 270 | 2. Your subclass overrides `send_delta()` |
| 271 | |
| 272 | If either is missing, the agent falls back to the normal one-shot `send()` path. |
| 273 | |
| 274 | ### Implementing `send_delta` |
| 275 | |
| 276 | Override `send_delta` to handle two types of calls: |
| 277 | |
| 278 | ```python |
| 279 | async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: |
| 280 | meta = metadata or {} |
| 281 | |
| 282 | if meta.get("_stream_end"): |
| 283 | # Streaming finished — do final formatting, cleanup, etc. |
| 284 | return |
| 285 | |
| 286 | # Regular delta — append text, update the message on screen |
| 287 | # delta contains a small chunk of text (a few tokens) |
| 288 | ``` |
| 289 | |
| 290 | **Metadata flags:** |
| 291 | |
| 292 | | Flag | Meaning | |
| 293 | |------|---------| |
| 294 | | `_stream_delta: True` | A content chunk (delta contains the new text) | |
| 295 | | `_stream_end: True` | Streaming finished (delta is empty) | |
| 296 | |
| 297 | ### Example: Webhook with Streaming |
| 298 | |
| 299 | ```python |
| 300 | class WebhookChannel(BaseChannel): |
| 301 | name = "webhook" |
| 302 | display_name = "Webhook" |
| 303 | |
| 304 | def __init__(self, config: Any, bus: MessageBus): |
| 305 | if isinstance(config, dict): |
| 306 | config = WebhookConfig(**config) |
| 307 | super().__init__(config, bus) |
| 308 | self._buffers: dict[str, str] = {} |
| 309 | |
| 310 | async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None: |
| 311 | meta = metadata or {} |
| 312 | if meta.get("_stream_end"): |
| 313 | text = self._buffers.pop(chat_id, "") |
| 314 | # Final delivery — format and send the complete message |
| 315 | await self._deliver(chat_id, text, final=True) |
| 316 | return |
| 317 | |
| 318 | self._buffers.setdefault(chat_id, "") |
| 319 | self._buffers[chat_id] += delta |
| 320 | # Incremental update — push partial text to the client |
| 321 | await self._deliver(chat_id, self._buffers[chat_id], final=False) |
| 322 | |
| 323 | async def send(self, msg: OutboundMessage) -> None: |
| 324 | # Non-streaming path — unchanged |
| 325 | await self._deliver(msg.chat_id, msg.content, final=True) |
| 326 | ``` |
| 327 | |
| 328 | ### Config |
| 329 | |
| 330 | Enable streaming per channel: |
| 331 | |
| 332 | ```json |
| 333 | { |
| 334 | "channels": { |
| 335 | "webhook": { |
| 336 | "enabled": true, |
| 337 | "streaming": true, |
| 338 | "allowFrom": ["*"] |
| 339 | } |
| 340 | } |
| 341 | } |
| 342 | ``` |
| 343 | |
| 344 | When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead. |
| 345 | |
| 346 | ### BaseChannel Streaming API |
| 347 | |
| 348 | | Method / Property | Description | |
| 349 | |-------------------|-------------| |
| 350 | | `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. | |
| 351 | | `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. | |
| 352 | |
| 353 | ## Config |
| 354 | |
| 355 | ### Why Pydantic model is required |
| 356 | |
| 357 | `BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied. |
| 358 | |
| 359 | Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**. |
| 360 | |
| 361 | ### Pattern |
| 362 | |
| 363 | 1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`: |
| 364 | |
| 365 | ```python |
| 366 | from pydantic import Field |
| 367 | from nanobot.config.schema import Base |
| 368 | |
| 369 | class WebhookConfig(Base): |
| 370 | """Webhook channel configuration.""" |
| 371 | enabled: bool = False |
| 372 | port: int = 9000 |
| 373 | allow_from: list[str] = Field(default_factory=list) |
| 374 | ``` |
| 375 | |
| 376 | `Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted. |
| 377 | |
| 378 | 2. Convert `dict` → model in `__init__`: |
| 379 | |
| 380 | ```python |
| 381 | from typing import Any |
| 382 | from nanobot.bus.queue import MessageBus |
| 383 | |
| 384 | class WebhookChannel(BaseChannel): |
| 385 | def __init__(self, config: Any, bus: MessageBus): |
| 386 | if isinstance(config, dict): |
| 387 | config = WebhookConfig(**config) |
| 388 | super().__init__(config, bus) |
| 389 | ``` |
| 390 | |
| 391 | 3. Access config as attributes (not `.get()`): |
| 392 | |
| 393 | ```python |
| 394 | async def start(self) -> None: |
| 395 | port = self.config.port |
| 396 | token = self.config.token |
| 397 | ``` |
| 398 | |
| 399 | `allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself. |
| 400 | |
| 401 | Override `default_config()` so `nanobot onboard` auto-populates `config.json`: |
| 402 | |
| 403 | ```python |
| 404 | @classmethod |
| 405 | def default_config(cls) -> dict[str, Any]: |
| 406 | return WebhookConfig().model_dump(by_alias=True) |
| 407 | ``` |
| 408 | |
| 409 | > **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth. |
| 410 | |
| 411 | If not overridden, the base class returns `{"enabled": false}`. |
| 412 | |
| 413 | ## Naming Convention |
| 414 | |
| 415 | | What | Format | Example | |
| 416 | |------|--------|---------| |
| 417 | | PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` | |
| 418 | | Entry point key | `{name}` | `webhook` | |
| 419 | | Config section | `channels.{name}` | `channels.webhook` | |
| 420 | | Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` | |
| 421 | |
| 422 | ## Local Development |
| 423 | |
| 424 | ```bash |
| 425 | git clone https://github.com/you/nanobot-channel-webhook |
| 426 | cd nanobot-channel-webhook |
| 427 | pip install -e . |
| 428 | nanobot plugins list # should show "Webhook" as "plugin" |
| 429 | nanobot gateway # test end-to-end |
| 430 | ``` |
| 431 | |
| 432 | ## Verify |
| 433 | |
| 434 | ```bash |
| 435 | $ nanobot plugins list |
| 436 | |
| 437 | Name Source Enabled |
| 438 | telegram builtin yes |
| 439 | discord builtin no |
| 440 | webhook plugin yes |
| 441 | ``` |
| 442 |