Channels and Gateway

Corresponding source

SubjectSource
Channel abstraction contractnanobot/channels/base.py::BaseChannel
Discovery, registration, and lifecyclenanobot/channels/registry.py, nanobot/channels/manager.py::ChannelManager
Pairing and access controlnanobot/pairing/store.py
Gateway startup and runtimenanobot/cli/commands.py::_run_gateway, nanobot/gateway/runtime.py, nanobot/gateway/service.py
Streaming and reasoning adaptationnanobot/channels/manager.py and concrete Channel implementations

Learning objectives

After this lesson, you should be able to:

  • explain the minimum BaseChannel contract;
  • describe discovery of built-in and external Channels;
  • explain the ordering of allowFrom, Pairing, and inbound publication;
  • describe how ChannelManager starts, stops, and delivers outbound messages;
  • explain why Gateway is the long-running assembly entry point.

Prerequisites

  • Understand MessageBus.
  • Understand InboundMessage and OutboundMessage.
  • Read Cron and Heartbeat.

1. BaseChannel is an adapter contract

Location:

  • nanobot/channels/base.py::BaseChannel

Required methods:

  • start()
  • stop()
  • send(OutboundMessage)

Optional overrides:

  • login()
  • send_delta()
  • send_reasoning_delta() / send_reasoning_end()
  • send_file_edit_events()
  • default_config()

start() should be a long-running operation that connects to the platform, receives messages, and passes them through _handle_message() to the shared inbound path.

2. Inbound messages pass access control first

BaseChannel._handle_message():

flowchart TD
    M["Platform message"] --> A{"Sender allowed?"}
    A -->|No, DM| P["Generate Pairing code<br/>send instructions directly"]
    A -->|No, not DM| D["Reject and log"]
    A -->|Yes| S{"Channel supports streaming?"}
    S -->|Yes| W["Add _wants_stream to metadata"]
    S -->|No| I["Create InboundMessage"]
    W --> I
    I --> B["bus.publish_inbound"]

is_allowed() precedence:

  1. allowFrom contains *;
  2. sender ID exactly matches an allowlist entry;
  3. Pairing store has approved the sender;
  4. otherwise reject.

Security implications:

  • allowFrom entries are opaque tokens compared exactly;
  • without allowFrom, a Channel may operate in pairing-only mode;
  • a DM can return a pairing code;
  • a group chat must not expose a pairing code publicly.

3. Streaming capability is configuration intersected with implementation

supports_streaming is true only when:

  • Channel configuration enables streaming;
  • the subclass actually overrides send_delta().

Writing streaming: true in configuration does not create streaming support.

A stateful Channel should separate segments with _stream_id; accumulating only by chat_id can join unrelated segments.

4. Channel discovery

Built-in Channels:

  • enumerate modules under nanobot.channels with pkgutil.iter_modules;
  • import only enabled entries, reducing failures from optional dependencies.

External Channels:

  • use Python entry-point group nanobot.channels.

Merge rule:

  • built-in implementations win;
  • a plugin cannot silently replace a built-in Channel with the same name.

Related source:

  • nanobot/channels/registry.py

5. ChannelManager initialization

ChannelManager._init_channels():

  1. discovers candidate names;
  2. merges plugin keys from Pydantic extra configuration;
  3. selects enabled Channels;
  4. imports only those classes;
  5. constructs additional Gateway services for WebSocket;
  6. resolves progress, tool-hint, and reasoning overrides;
  7. validates allowFrom;
  8. stores the instances.

If one optional Channel cannot initialize, Manager logs a warning rather than preventing every Channel from starting.

6. Start and stop

start_all():

  • creates the outbound-dispatcher Task;
  • creates one long-running Task per Channel;
  • waits with gather;
  • expects a healthy Channel start() not to return immediately.

stop_all():

  • cancels the dispatcher first;
  • waits for cancellation;
  • calls each Channel’s stop();
  • respects real Task cancellation.

7. Outbound dispatcher

_dispatch_outbound() continuously consumes MessageBus outbound and routes by metadata:

  • complete messages;
  • content deltas;
  • stream end;
  • reasoning delta/end;
  • file-edit events;
  • progress/tool hints.

It also handles:

  • selecting the target Channel;
  • retries;
  • delta coalescing;
  • duplicate-message suppression;
  • failed-send buffering and logs.

Channel send() should raise on failure so Manager can apply retry centrally instead of silently swallowing the error.

8. Delivery retry

ChannelManager centralizes outbound-delivery retries. Current configuration:

  • channels.sendMaxRetries

Verify the delay sequence and actual attempt count in _send_with_retry().

This retry is distinct from Provider retry:

Provider retry: model request failed
Channel retry: final message delivery failed

9. Gateway assembly

nanobot/cli/commands.py::_run_gateway creates:

  • MessageBus;
  • RuntimeEventBus;
  • Provider snapshot;
  • SessionManager;
  • CronService;
  • AgentLoop;
  • WebuiTurnCoordinator;
  • ChannelManager.

It then starts AgentLoop, Cron, Channels, and the optional health server.

Gateway is not another Agent. It is the process entry point that gives these long-running components one shared lifecycle.

If a Channel does not respond:

  1. Does nanobot agent -m succeed?
  2. Is the Channel enabled?
  3. Can optional dependencies import?
  4. Is start() still running?
  5. Did the sender pass allowFrom/Pairing?
  6. Did _handle_message() publish inbound?
  7. Did AgentLoop consume it?
  8. Did outbound reach the dispatcher?
  9. Did send() raise and retry?
  10. Did the platform API accept the message format?

Source and tests

  • nanobot/channels/base.py
  • nanobot/channels/registry.py
  • nanobot/channels/manager.py
  • nanobot/pairing/store.py
  • nanobot/cli/commands.py::_run_gateway
  • 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
  • tests/pairing/

Self-check

  1. Why must _handle_message() perform access control before publishing?
  2. Why import only enabled Channels?
  3. Why is enabling streaming in configuration insufficient?
  4. Why must Provider retry and Channel retry remain separate?
  5. Why is Gateway an assembly entry point rather than another Agent?

Next step

Read HTTP API and Python SDK.