← Documentation index Architecture guide › channel

Metnos

channel — how Metnos receives from and replies to the world
Guide to conversational channels

Channels translate the browser, HTTP API, and Telegram into the runtime's shared message contract. Identity, autonomy level, dialog state, and reply destination remain bound to the authenticated user and channel: a message cannot inherit another person's context.

Ask Metnos with a request like this example: “Send Lucia the summary of today's meeting on Telegram.” Metnos resolves the name through the channels associated with that user: you do not need to know or enter a Telegram chat_id. Delivery remains subject to the controls that apply to the requester and recipient.

Contents

  1. What it does: the channel adapter
  2. The Protocol and the types
  3. The Telegram channel
  4. Long-poll and offset persistence
  5. Daemon: the loop poll → run_turn → send
  6. Inline buttons and interactive actions
  7. Relationship with pairing
  8. Distribution: systemd user unit
  9. Web chat and HTTP API
  10. Operational constraints

1. What it does: the channel adapter

A channel connects a conversational interface to the runtime. The web chat invokes the HTTP API directly; Telegram uses a long-polling adapter. In both cases, the transport supplies text, attachments, and authenticated metadata, while the runtime handles planning, execution, dialogs, and safety controls.

web chat / Telegram request / long polling channel gateway identity / message agent runtime handles the turn reply originating channel
Figure 1 — Web and Telegram converge on the runtime after identity resolution; the reply returns to the channel that originated the turn.

The contract separates transport from agent logic. The Telegram channel normalises incoming updates and outgoing replies; the HTTP server exposes the same runtime through a request-response model. In both cases, identity is resolved before the message content is processed.

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 transports that support interactive actions.

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. The Telegram channel

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. The encrypted credential store, using the telegram_bot_token and telegram_chat_id_host domains.
  4. The legacy ~/.config/metnos/credentials.env file (mode 600), retained as a compatibility fallback.

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 acknowledged update — is kept in memory and written atomically to ~/.local/state/metnos/telegram_offset. If the file is unreadable or damaged, the channel starts without an offset instead of failing.

Persistence is optional: state_path=False disables it, primarily for tests. The daemon calls ack() only after handling an update. If handling the message or writing its offset fails, that update remains unacknowledged and can be delivered again on the next polling cycle.

5. Daemon: the loop poll → run_turn → send

runtime/channels/daemon.py contains ChannelDaemon: a process that performs poll → for each message: handle → run_turn → send sequentially. Calls to run_turn are synchronous. The same process may start the remote-executor HTTP listener in a separate thread; this does not make Telegram message handling concurrent.

ChannelDaemon works against the Channel Protocol; Telegram-specific work, such as attachment downloads and callback replies, stays in the adapter or its dispatchers.

Error discipline:

Options for ./.venv/bin/python -m runtime.channels.daemon, run from the repository root:

6. Inline buttons and interactive actions

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 callback_data value identifies the action semantically and is independent of the displayed text. The daemon currently handles:

Button labels are localised in the user's language. Before applying a decision, the daemon verifies the identity bound to the channel and ownership of the pending state; possessing or forwarding a callback value grants no authority. Approvals and dialogs are one-shot and expire. See approvals and human control for the interaction contract.

7. Relationship with pairing

The daemon does not accept messages from just anyone. Before processing an InboundMessage, it resolves the binding between the channel, sender identifier, and Metnos user. It consults the pairing registry and, for multi-user bindings, the user registry (see pairing):

The /pair <code> and /start <token> commands are intercepted before the pairing check so an unrecognised sender can use them. The first consumes a signed code carrying an autonomy level; the second consumes the short-lived, one-shot token issued by user management. Every message from a paired sender also updates last_seen through pairing.touch_last_seen.

8. Distribution: systemd user unit

The daemon is distributed as a user unit, so it runs as the user who installed Metnos rather than as root. Its canonical source is install/units/metnos-telegram-daemon.service.tmpl; the installer renders it to ~/.config/systemd/user/metnos-telegram-daemon.service only when Telegram has been configured.

To keep user services active after logout and across reboots, a system administrator can enable lingering once for the service owner: sudo loginctl enable-linger "$USER". Management of the whole service group is documented in systemd/README.md.

9. Web chat and HTTP API

The web chat communicates with the Metnos HTTP server on port 8770. On the agent side the main routes are POST /agent/turn (runs run_turn, in SSE or JSON), GET /agent/devices/me, GET /agent/health and GET /.well-known/metnos.json. For the full list see http_api.

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. Operational constraints