channel — how Metnos receives from and replies to the worldChannels 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.
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.
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.
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 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.
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.telegram_bot_token and telegram_chat_id_host domains.~/.config/metnos/credentials.env file (mode 600), retained as a compatibility fallback.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-polls
getUpdates with an internal offset; it normalises text messages,
photos, locations, callback_query events, and updates with no
actionable content. Button clicks are forwarded to the dispatcher described
in ch. 6.
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.
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:
poll: log + sleep 5 s +
retry on the next tick. No crash.send: caught by
TelegramChannel._call, returns dict {ok: False, error}.
The failure is logged, and the daemon applies the configured fallback delivery
paths for the reply.Options for ./.venv/bin/python -m runtime.channels.daemon, run
from the repository root:
--channel telegram: selects the adapter; Telegram is the only
available value.--dry-run: processes the turn and logs the response, but does
not send the final reply through the channel. It is not a sandbox:
run_turn still executes and executors may have effects.--no-bootstrap: disables automatic pairing of the
default_chat_id (ch. 7), so the host user must also complete
an explicit pairing.--no-agent-server: does not start the remote-executor HTTP
listener. Without this option, the listener runs in a separate thread on
127.0.0.1:8765.--agent-host and --agent-port: change the bind
address and port of the remote-executor listener.-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 callback_data value identifies the action semantically and is
independent of the displayed text. The daemon currently handles:
dlg: for answers to guided-dialog steps;cap: for administrative approvals and operations that need confirmation;promoter: and sched: for proposals and scheduled work;loc_cancel to cancel a location request;approve: and reject: for older requests that are
still present in the approval store.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.
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):
autonomy_level == "ReadOnly", it may still
answer informational requests recognised by the Tutor, but it does not start
an operational turn (LEVEL_BLOCKS_RUN in daemon.py).default_chat_id and no other pairing
exists on that channel, it performs an automatic bootstrap at
Full (_try_bootstrap). The
--no-bootstrap option disables this behaviour.
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.
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.
ExecStart uses the installation virtual environment and runs
runtime.channels.daemon from the repository root.metnos.target and starts after the HTTP
server.Restart=on-failure, with a 10-second delay before retrying.journalctl --user -u metnos-telegram-daemon.service -f.
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.
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:
run_turn receives channel="http" and goes through the same controls (vaglio, policy) as Telegram.HTTPChannel: the polling model of the Channel Protocol does not match request-response. POST /agent/turn therefore calls run_turn directly.
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.