Add a Channel

Corresponding source

Extension stepSource
Channel base contractnanobot/channels/base.py::BaseChannel
Discovery and plugin loadingnanobot/channels/registry.py, nanobot/channels/manager.py
Inbound messages, security, and Pairingnanobot/channels/manager.py, nanobot/pairing/store.py
Outbound, streaming, reasoning, and mediananobot/bus/events.py, nanobot/channels/manager.py, concrete Channels
Configuration integrationnanobot/config/schema.py, nanobot/cli/commands.py::onboard
Teststests/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.channels entry-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:

  1. checks allowFrom or the pairing store;
  2. optionally generates a pairing code for an unauthorized direct message;
  3. sets _wants_stream according to streaming support;
  4. constructs an InboundMessage;
  5. 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:

  1. allowFrom contains "*";
  2. an exact sender match exists;
  3. the pairing store has approved the sender;
  4. 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.content
  • msg.media
  • msg.chat_id
  • msg.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:

  1. its configuration sets streaming to true;
  2. 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_progress
  • send_tool_hints
  • send_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.channels entry-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

  1. The registry discovers the entry point.
  2. Disabled configuration does not instantiate the Channel.
  3. start() begins work and stop() cleans it up.
  4. An allowed sender produces the correct InboundMessage.
  5. Denial and pairing behave correctly.
  6. send() uses the right destination and format.
  7. The manager retries delivery failures.
  8. Streaming delta/end events respect _stream_id.
  9. Media handling enforces its security boundary.
  10. Cancellation propagates.

Test references:

  • tests/channels/test_base_channel.py
  • tests/channels/test_channel_plugins.py
  • tests/channels/test_channel_manager_delta_coalescing.py
  • tests/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 BaseChannel subclass;
  • 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

  1. Why should a platform callback call _handle_message()?
  2. What can go wrong if sender_id and chat_id are confused?
  3. Why must a Channel’s start() method not return immediately after connecting?
  4. Which implementation wins when a built-in Channel and an external plugin share a name?

Next: Command, API, SDK, and WebUI extension boundaries