channel — how Metnos receives from and replies to the worldA 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.
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):
| Type | Fields | Notes |
|---|---|---|
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.
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):
TelegramChannel(token=, default_chat_id=, credentials_path=, state_path=).TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID.~/.config/metnos/credentials.env (chmod 600), dotenv format.Methods:
send(recipient, message) → POST sendMessage
to api.telegram.org. If buttons, it builds an
inline_keyboard. Returns {ok, result?, error?, status_code?}.poll(timeout_s=25) → long-poll getUpdates
with internal offset; returns only the message and
edited_message entries with a text field. The
callback_query events (clicked buttons) will be handled in v1.2
with a separate dispatcher (see ch. 6 and 9).
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.
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):
poll: log + sleep 5 s +
retry on the next tick. No crash.run_turn: caught, reply to the
sender with (internal error: ...), the daemon continues.send: caught by
TelegramChannel._call, returns dict {ok: False, error}.
The turn is already closed on the runtime side; the user must retry.Modes (CLI python3 -m channels.daemon):
--dry-run: logs the responses but does not send them. Useful
to observe the runtime's behaviour without real effects.--no-bootstrap: disables the auto-pair of the
default_chat_id (ch. 7). Needed for multi-user setups,
where Roberto too pairs explicitly with a code.-v: log at DEBUG level.
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.
pairing
The daemon does not accept messages from anyone. For each
InboundMessage it consults pairing.get_pairing(channel, sender_id)
(see pairing):
run_turn, except in the autonomy_level == "ReadOnly"
case in which it replies with a polite message and does not perform actions
(LEVEL_BLOCKS_RUN in daemon.py).Full
(_try_bootstrap). Without this, at install time Roberto would
have to generate a code for himself — useless step in dev.UNPAIRED_REPLY).
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.
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:
NoNewPrivileges=trueProtectSystem=strict + ReadWritePaths limited
to ~/.local/state/metnos and ~/.local/share/metnosPrivateTmp=true, ProtectKernelTunables,
ProtectKernelModules, ProtectControlGroups,
RestrictNamespaces, RestrictRealtime,
LockPersonalityjournalctl --user -u metnos-telegram-daemon -f
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.
poll
explicitly filters out messages that have no text. Photos will
be handled by downloading the attachment to workspace/inbox/ and
passing the path to the runtime; voice notes via local
whisper.cpp (ch. 4 Architecture, open-source-first
principle).send level would suffice.