TESTED Microdesign v1.1 — new, tested on April 27, 2026. The channels component was implemented and tested at module level (channels cluster 19/19 green) on 27/4/2026. Implementation reference: /opt/myclaw/runtime/channels/.
Status sequence of the microdesign: under approvalapprovedtestedimplemented. The v1.0 of this doc preceded the code; it is replaced by this v1.1 after the implementation of Channel Protocol, TelegramChannel, ChannelDaemon and the integration with the pairing module.
← Documentation index Microdesign › channel

Metnos

channel — how Metnos receives from and replies to the world
Microdesign v1.1 — status TESTED (April 27, 2026)
Audience: those who want to understand how to add a conversational interface.

Reading time: 10 minutes.

Contents

  1. What it does: the channel adapter
  2. The Protocol and the types
  3. Telegram as the first implementation
  4. Long-poll and offset persistence
  5. Daemon: the loop poll → run_turn → send
  6. Inline buttons and approval rendering
  7. Relationship with pairing
  8. Distribution: systemd user unit
  9. Limits and deferrals to v1.2+

1. What it does: the channel adapter

A channel, in Metnos, is an adapter: it converts an external interface (local CLI, Telegram bot, voice, in the future Signal) into a stream of messages spoken to the runtime, and returns the responses back. Nothing more. All the conversational logic — planning, execution, vaglio — lives in the runtime; the channel is a bidirectional pipe.

The choice to treat the channel as an adapter (and not as a distinct module for each integration) is what makes it possible to start with two channels (CLI + Telegram) and add more without touching the core. See ch. 6 of the Architecture: the channel sits in layer 1 (Gateway), together with authentication and routing.

2. The Protocol and the types

In runtime/channels/__init__.py the channel is a typing.Protocol with two minimal methods and a name property. No base class, no inheritance: any object that exposes the right shape is a channel.

@runtime_checkable
class Channel(Protocol):
    name: str
    def send(self, recipient: str, message: OutboundMessage) -> dict: ...
    def poll(self) -> list[InboundMessage]: ...

The two message types are dataclass(frozen=True):

TypeFieldsNotes
InboundMessage channel, sender_id, text, message_id, received_at, extra Normalised: sender_id is always a string even where the channel handles it as int (Telegram chat_id). extra for channel-specific metadata (e.g. from, update_id).
OutboundMessage text, reply_to, buttons buttons is list[list[dict]] (rows of buttons) for the channels that support them. On CLI it is ignored.

The frozenness prevents the dispatcher from modifying an incoming message: whoever wants to enrich it creates a new object. It is a deliberate choice: a message that has arrived is a historical fact, not a scratch pad.

3. Telegram as the first implementation

runtime/channels/telegram.py implements the protocol via the Bot API. No external library: just urllib + json. Coherent with the self-host principle (ch. 4 Architecture): the bot queries outbound on api.telegram.org, no open ports, no public IP.

Configuration (in order of precedence):

  1. Constructor parameters: TelegramChannel(token=, default_chat_id=, credentials_path=, state_path=).
  2. Environment variables: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID.
  3. File ~/.config/metnos/credentials.env (chmod 600), dotenv format.

Methods:

4. Long-poll and offset persistence

getUpdates is called in long-poll mode: the Telegram server keeps the connection open for up to 25 seconds if there are no updates. No busy-loop, no useless load, minimal latency when a message arrives.

The offset (the update_id of the last update seen) is kept in memory. To survive restarts (systemd restart, crash, new code deploy) it is also written to disk in ~/.local/state/metnos/telegram_offset. Round-trip verified in test (telegram_offset_persistito_round_trip); a corrupted file falls back to "offset = none" without crashing (telegram_offset_state_corrotto_torna_none).

Persistence is optional: state_path=False disables it (useful in tests). Without persistence, after a restart the bot sees again the last pending messages on Telegram and risks double replies. With persistence, the first poll after the restart starts from offset = last_seen + 1 and Telegram does not redeliver already-acknowledged messages.

5. Daemon: the loop poll → run_turn → send

runtime/channels/daemon.py contains ChannelDaemon: a single process that cycles poll → for each message: handle → (run_turn) → send. No threading. The calls to run_turn are synchronous. Typical latency for a local turn: 2-15 s.

The daemon does not «know» that the channel is Telegram: it works on the Channel Protocol. When Signal arrives, the same daemon starts with a different instance.

Error discipline (test daemon_run_turn_eccezione_non_crasha_loop):

Modes (CLI python3 -m channels.daemon):

6. Inline buttons and approval rendering

OutboundMessage.buttons is a matrix list[list[dict]]. Each dict has text and data; data ends up in the callback_data of the Telegram button.

The component that produces the approval cards is in runtime/channels/approval.py: render_approval_card(ApprovalRequest) returns an OutboundMessage with the «3-line card» of the dialog manager (see approval_ux v1.0, to be promoted to v1.1 once the dispatcher arrives). Three forms modulated by recurrence:

The card can already be rendered; the dispatching of callbacks (clicking "Approve" resolves the token into a pending request and applies the decision) is deferred to v1.2 because it requires a registry of pending requests with TTL, which is the natural next iteration now that the real vaglio (27/4) can start raising concrete approval requests.

7. Relationship with pairing

The daemon does not accept messages from anyone. For each InboundMessage it consults pairing.get_pairing(channel, sender_id) (see pairing):

The /pair <code> command on the channel is intercepted before the pairing check — that is its very purpose. Every message from a paired sender also updates the last_seen via pairing.touch_last_seen, useful for audit and to detect dormant accounts to revoke.

8. Distribution: systemd user unit

The daemon runs as a user unit, not as a system service: no root, no privileges. The template file is in /opt/myclaw/systemd/metnos-telegram-daemon.service. Explicit hardening:

Restart=on-failure with 5s backoff. The user linger is required for the service to stay active without an interactive login: loginctl enable-linger $USER (sudo required the first time). The README in /opt/myclaw/systemd/README.md has the full procedure.

9. Limits and deferrals to v1.2+