✓ Microdesign — aligned with the code. The channels component is implemented and tested at module level. Supports multi-user: /start <token> guest pairing command, send_to(chat_id, OutboundMessage) and multi-user fallback for pairings missing in pairings.db. Read alongside http_api: Telegram remains the primary dialogue channel; HTTP (port 8770) is the alternative channel for the host dashboard, curl/script automations and the remote Rust client. Implementation reference: /opt/metnos/runtime/channels/.
← Documentation index Microdesign › channel

Metnos

channel — how Metnos receives from and replies to the world
Microdesign
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. HTTP as alternative channel
  10. Limits and deferrals to +

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.

Telegram long-poll channel adapter send / poll agent runtime handles the turn reply send_to(chat_id)
Figure 1 — The channel adapter: Telegram on long-poll, normalized into a uniform interface, routed to the runtime and answered per chat.

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). Three forms modulated by recurrence:

The dispatching of callbacks (clicking "Approve" resolves the token into a pending request and applies the decision) is handled by the daemon via approval_registry.resolve (see approval_ux).

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/metnos/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/metnos/systemd/README.md has the full procedure.

9. HTTP as alternative channel

Since Metnos exposes a second HTTP server on port 8770 with three agent-side routes: POST /agent/turn (run_turn execution with SSE or JSON), GET /agent/devices/me, GET /agent/health and GET /.well-known/metnos.json. See http_api for the details.

The relationship between channel and http_api is deliberate:

Multi-user: the /start <token> command accepted by the Telegram daemon completes the guest pairing issued from the /admin/users panel of the HTTP API. The send_messages supports to_user="lucia" and via_channel="auto": it resolves through users.resolve_recipients, with cross-user vaglio applied for non-host actors.

10. Limits and deferrals to +