Add a Channel
Corresponding source
| Extension step | Source |
|---|---|
| Channel base contract | nanobot/channels/base.py::BaseChannel |
| Discovery and plugin loading | nanobot/channels/registry.py, nanobot/channels/manager.py |
| Inbound messages, security, and Pairing | nanobot/channels/manager.py, nanobot/pairing/store.py |
| Outbound, streaming, reasoning, and media | nanobot/bus/events.py, nanobot/channels/manager.py, concrete Channels |
| Configuration integration | nanobot/config/schema.py, nanobot/cli/commands.py::onboard |
| Tests | tests/channels/, tests/pairing/ |
Learning objectives
After reading this chapter, you should be able to:
- implement a minimal inbound and outbound adapter with
BaseChannel; - publish an external plugin through the
nanobot.channelsentry-point group; - handle allowlists, pairing, streaming, reasoning, media, and shutdown correctly;
- test with a fake platform client instead of connecting to a real chat platform.
Prerequisites
- Understand Channels and Gateway.
- Be able to write long-running asynchronous tasks.
- Know that platform user IDs, chat IDs, and message IDs are not necessarily the same identifier.
1. A Channel’s minimum responsibility
A Channel is a platform adapter:
flowchart LR A["Platform event"] --> B["Parse sender, chat, content, and media"] B --> C["BaseChannel._handle_message"] C --> D["InboundMessage"] D --> E["MessageBus"] E --> F["Agent core"] F --> G["OutboundMessage"] G --> H["Channel.send / send_delta"] H --> I["Platform API"]
It should not duplicate AgentLoop, Session, or Tool-execution logic.
2. Required BaseChannel methods
A subclass must implement:
async start()async stop()async send(msg)
start() must remain active until stop() is called or its task is canceled. If it returns immediately after establishing a connection, ChannelManager treats that Channel as finished.
Define at least these class attributes:
name = "webhook"
display_name = "Webhook"3. Inbound events must pass through _handle_message
A platform callback should call:
await self._handle_message(
sender_id=sender,
chat_id=chat,
content=text,
media=media,
metadata=metadata,
session_key=optional_override,
is_dm=is_direct_message,
)BaseChannel._handle_message() then:
- checks
allowFromor the pairing store; - optionally generates a pairing code for an unauthorized direct message;
- sets
_wants_streamaccording to streaming support; - constructs an
InboundMessage; - publishes it to
MessageBus.
Calling bus.publish_inbound() directly bypasses these shared behaviors. Do so only for a reviewed, explicit reason.
4. Model identity explicitly
Define the role of each identifier:
sender_id: who sent the event;chat_id: where the response should go;session_key: which messages share a Session;message_id: what identifies a reply target, deduplication key, or thread item;- group, thread, and reply IDs: the platform’s own hierarchy.
A common mistake is to treat a group chat ID as a user’s allowlist ID, or to let separate threads share a Session accidentally.
Preserve stable platform IDs as strings. Do not convert an ID that may exceed JavaScript’s safe-integer range to a number.
5. Allowlist and pairing
BaseChannel.is_allowed() currently evaluates access in this order:
allowFromcontains"*";- an exact sender match exists;
- the pairing store has approved the sender;
- otherwise, deny access.
Entries in allowFrom are opaque tokens and must match exactly. An empty list denies every unpaired user. If the field is omitted, ChannelManager logs a pairing-only notice.
A new Channel should reuse this logic and supply the platform’s real, stable user identifier.
6. Outbound delivery and retry
send() receives:
msg.contentmsg.mediamsg.chat_idmsg.metadata
Raise an exception when delivery fails. ChannelManager._send_with_retry() applies the shared 1-, 2-, and 4-second backoff; each Channel should not wrap the same delivery in another identical retry layer.
Cancellation must propagate so that Gateway can shut down cleanly.
If the platform API already retries, avoid stacking two retry mechanisms that could produce duplicate messages.
7. Streaming
A Channel advertises streaming only when both conditions hold:
- its configuration sets
streamingto true; - the subclass overrides
send_delta().
Handle these metadata fields:
_stream_delta_stream_end_stream_id_resuming
Any stateful buffer must be keyed by _stream_id, not only by chat_id. One chat may contain concurrent segments, resumed streams, or mirrored browser tabs.
If a platform is poorly suited to frequent edits, leave streaming disabled and use send() for the final response.
8. Progress, Tool hints, reasoning, and file edits
Optional surfaces include:
send_progresssend_tool_hintssend_reasoning_delta()send_reasoning_end()send_file_edit_events()
These carry lower-priority observational information. A platform may safely ignore unsupported surfaces, but it must not send reasoning as an ordinary final reply.
ChannelManager also coalesces consecutive text deltas to reduce platform API calls.
9. Media
Review Channel media code separately for:
- upload size and type limits;
- temporary-file cleanup;
- SSRF in remote URL downloads;
- path traversal through filenames;
- MIME and extension mismatches;
- platform download tokens;
- whether outbound local files remain under an allowed media root;
- escaping when converting Markdown to a platform format.
Use the existing focused tests as references instead of copying only a happy path.
10. Prefer an external plugin
The official recommendation is an independent package:
[project.entry-points."nanobot.channels"]
webhook = "nanobot_channel_webhook:WebhookChannel"ChannelsConfig accepts extra sections. ChannelManager looks for enabled Channels among built-in module names and extra configuration keys.
Discovery follows these rules:
- built-in Channels are scanned with
pkgutil; - plugins use the
nanobot.channelsentry-point group; - only enabled built-in modules are imported, reducing third-party SDK startup cost;
- a built-in Channel wins a name collision, so an external plugin cannot shadow it.
A plugin can implement default_config() so that nanobot onboard generates initial configuration.
11. Minimum test set
- The registry discovers the entry point.
- Disabled configuration does not instantiate the Channel.
start()begins work andstop()cleans it up.- An allowed sender produces the correct
InboundMessage. - Denial and pairing behave correctly.
send()uses the right destination and format.- The manager retries delivery failures.
- Streaming delta/end events respect
_stream_id. - Media handling enforces its security boundary.
- Cancellation propagates.
Test references:
tests/channels/test_base_channel.pytests/channels/test_channel_plugins.pytests/channels/test_channel_manager_delta_coalescing.pytests/channels/test_channel_manager_reasoning.py- the test for an existing Channel with a similar protocol.
Complete official example:
12. Minimum modification scope
An external plugin needs:
- an independent package and configuration model;
- a
BaseChannelsubclass; - an entry point;
- tests and a README.
A built-in Channel may additionally require:
- an optional dependency;
- user-facing configuration documentation;
- onboarding support;
- WebUI settings;
- platform-specific security tests.
Do not change the fixed fields of ChannelsConfig merely for an experiment. Its extra-configuration boundary already supports plugins.
Check your understanding
- Why should a platform callback call
_handle_message()? - What can go wrong if
sender_idandchat_idare confused? - Why must a Channel’s
start()method not return immediately after connecting? - Which implementation wins when a built-in Channel and an external plugin share a name?