Telegram
A bot DM becomes a session. The agent is the persona and the bot is its identity: its own name, avatar, and audience. Create a bot with Telegram's @BotFather (it takes a minute and hands you a token), declare the token's env var in the agent's agent.yaml:
telegram:
bot_token_env: REPORTER_BOT_TOKENthen set that variable on the workers and the channel comes up with them:
REPORTER_BOT_TOKEN=123456:ABC... toren dev --dir .Each agent that should be reachable on Telegram gets its own BotFather bot and its own bot_token_env. A bot sees exactly one agent and nothing else: someone you pair with the reporter bot cannot see, list, or reach the rest of the fleet. That is the shape for real audiences — an on-call bot, a reporting bot for a customer, a personal assistant.
The fleet bot (operators only)
There is a second mode: set TELEGRAM_BOT_TOKEN on the workers (no agent.yaml needed) and you get one fleet bot that reaches every agent in the deployment — /agent lists the roster, /new <agent> switches. It is a switchboard for you, the operator.
Do not hand fleet-bot invites to outsiders. Anyone paired with it sees the full roster and can talk to any agent. Give people dedicated bots instead.
The modes mix freely: only dedicated bots, only the fleet bot, or both at once. Every isolation boundary is per bot:
- Pairing is per bot. Being paired with one bot grants nothing on another. Mint codes for a dedicated bot with
toren channels telegram invite --agent <name>; a bareinvitemints for the fleet bot. - Conversations are per bot. The same person talking to two bots holds two independent sessions.
TELEGRAM_ALLOWED_USERSapplies to all bots — it is the operator's own allowlist, not an audience boundary. Use pairing codes to give different people different bots.
Bot identity is stored by agent name, not by token, so rotating a leaked token (via @BotFather) keeps every pairing and open conversation intact: swap the env value and restart.
On AWS, dedicated bot tokens are your own env vars: wire them like any other secret, through agent_env_secret_arns (AWS guide). The fleet bot's TELEGRAM_BOT_TOKEN is handled by deploy-aws itself and stored in Secrets Manager.
Who can talk to it
Nobody, until you say so. The bot is deny-by-default: a stranger who finds it gets a polite refusal, whatever they send. Two ways in:
Pairing codes. Mint a one-time code and hand it to the person:
bashtoren channels telegram invite --agent reporter # for an agent's dedicated bot toren channels telegram invite # for the fleet botThey DM the code to the bot, the code burns, and they are paired from then on. On a deployment, mint codes through the API instead:
POST /channels/telegram/inviteswith the admin token.Allowlist. Set
TELEGRAM_ALLOWED_USERSto comma-separated numeric Telegram user IDs. Those users are always in, no code needed.
Talking
Just send a message: it continues your open conversation, or starts one. The bot shows a typing indicator while the agent works.
| Command | What it does |
|---|---|
/new [agent] | Start a fresh conversation ([agent] only matters on the fleet bot) |
/agent | Who you are talking to; on the fleet bot, the full roster |
/approve / /deny | Answer a pending tool approval (optionally with a comment) |
/end | Close the open conversation |
Approvals happen in the chat
When the agent hits a gated tool (sandbox bash defaults to requiring approval), the bot sends the pending call into the conversation — tool name and arguments — and waits. Reply /approve or /deny, with an optional comment the agent will see. The run parks at zero compute while it waits, and the approval is recorded in the event log like everything else, so a resumed run never re-asks. No more choosing between sandboxes and approval: never on this channel.
Files arrive as files
An agent with a sandbox can hand its work over with the built-in send_to_channel tool: the file lands in the chat as a photo (images) or a document (everything else), with an optional caption. Delivery goes through a durable outbox, so a worker crash mid-upload re-sends rather than losing the file. If a run has no bound chat, the tool tells the model so in plain words instead of letting it invent a download link.
Observer mode: bots that listen and never speak
Some bots exist to watch, not talk — a silent logger in a busy group, a monitor collecting signals. Declare it per agent:
telegram:
bot_token_env: AFF_BOT_TOKEN
groups: observe
observe:
updates: [message, edited_message, my_chat_member]With groups: observe, everything the bot sees in group chats is recorded to toren_control.telegram_observations — sender, text, media type and file_id, edits, membership changes (a kick shows up as my_chat_member), plus the raw update as JSON. No replies, no pairing prompts, no runs, no model calls. DMs keep normal conversation behavior, so the same bot can converse privately and observe publicly.
Nothing executes per observation, by design: processing belongs in a scheduled process that sweeps the table in batch — one run classifying hundreds of observations beats a run per message on every axis, and your logic stays durable and auditable instead of living inside a poller. WHERE bot_key = 'agent:<name>' AND id > $last is the whole consumption contract.
The observations table is a stable, documented interface — unlike the channel's internal tables, it is meant to be queried, and its columns fall under the versioning promise.
Pairing never gated what a bot can hear — it gates who can talk to the agent. The authorization for observing a group is membership: you put the bot there.
The model knows it's on Telegram
Two layers keep replies readable without per-deployment prompt patches:
- A channel primer. Sessions born on Telegram get a short, constant addition to the system prompt describing the pipe: markdown doesn't render, keep replies short, deliver files with
send_to_channel, no tables. The primer is keyed to the run's birth channel and never changes mid-run, so replay digests stay stable; sessions created before this feature keep their old prompt untouched. - Rendering at delivery. Outbound text is translated to Telegram's HTML subset:
**bold**and`code`render properly, headings become bold lines, markdown tables arrive as aligned monospace blocks, real links become links, and fabricated ones (a model inventingsandbox:/paths) degrade to plain text. Anything Telegram's parser still refuses falls back to plain text — a formatting mistake can never wedge delivery.
Durability, same as everywhere else
The channel runs inside the workers, and any worker can host it: they race for a Postgres advisory lock (one election per bot) and exactly one worker polls each bot at a time. If that worker dies, another takes over within seconds. Inbound updates are deduplicated through the database, and outbound replies advance a delivered-cursor with a compare-and-swap, so a crash mid-delivery never double-sends a turn and never drops one. Your chat survives deploys, worker kills, and everything else the runtime survives, because it is the runtime.
Observability: a dead poller must be loud
A quiet hour and a dead poller must never look the same. Three guarantees, added after a production incident where 22 silent hours could not be diagnosed:
- Nothing exits silently. Every failure path in the channel logs on the transition into failure and keeps retrying; a database blip at boot or mid-flight can no longer kill a loop for the life of the process.
- A heartbeat line (
poller alive, offset N) prints every 5 minutes while polling is healthy, so "no logs" now always means "not healthy". GET /healthzreports channel health: each bot appears underchannelswithelected,polling,lastPollOkAt,lastUpdateId,lastError, andconsecutiveFailures. The endpoint is unauthenticated (it exists for load balancers), so point your alerting at it:lastPollOkAtolder than a few minutes means the poller is stuck, whatever the process state says.