Telegram Remote Steering
For developers: see
docs/telegram-architecture.mdfor the internal architecture and contribution guide.
Sending and receiving files: see
docs/telegram-attachments.mdfor the inbound media + outboundsend_attachmentoperator guide.
Status: Operator guide (M5.2). Companion to the threat model in
docs/telegram-threat-model.md.
agents-fleet ships an opt-in Telegram bot transport (TelegramChannel) that lets a paired operator drive a long-running fleet from their phone. The fleet keeps running on your host; the bot is a thin steering surface — every command you send is the same slash command you would type into the REPL, gated by explicit confirmation flows for anything destructive.
This document covers everything you need to pair, use, recover, and troubleshoot a Telegram-paired fleet. If you only need the security model, jump to Security model or read the full threat model (docs/telegram-threat-model.md).
At a glance
| You want to… | Do this |
|---|---|
| Pair your phone the first time | Quick start |
| Know what commands are usable from Telegram | Available commands |
| Understand permission prompts (allow / deny / always) | Permission flows |
| Understand SEC three-tap confirmations | Multi-tap SEC confirmation |
| Read the security disclaimer (token = full control) | Security model |
| Stop the bot or revoke a phone | Recovery procedures |
| Fix a flag/auth/rate-limit problem | Troubleshooting |
Quick start
1. Create a Telegram bot
Talk to @BotFather in Telegram and run /newbot. Follow the prompts; BotFather returns a bot token that looks like 1234567890:AaBbCc-…. Treat this token like a production secret — anyone who has it can impersonate the bot and read every message ever sent to it. See Security model.
2. Export the token on your fleet host
# macOS / Linux
export AGENTS_FLEET_TELEGRAM_TOKEN="1234567890:AaBbCc-..."# Windows PowerShell
$env:AGENTS_FLEET_TELEGRAM_TOKEN = "1234567890:AaBbCc-..."Persist it in your shell rc / profile so it survives reboots. The value is never printed by any agents-fleet command — agents-fleet bot status only reports a boolean "token configured: yes".
3. Issue a one-time pairing token
agents-fleet bot pairOutput (token shown ONCE — copy it now):
── Telegram pairing token ──
Copy this token and send it to your bot in Telegram:
/start a1b2c3d4e5f6...
Expires at: 14:32:10 (5 minutes)
This token will be shown ONCE. If you lose it, run `agents-fleet bot pair`
again — any older pending token is invalidated.4. DM your bot
Open Telegram, find your bot by its username (the one you set with BotFather), and send the literal line:
/start a1b2c3d4e5f6...The bot replies with ✅ Paired and is now linked to your Telegram chat. The pairing is per chat — pair from a second device by running bot pair again from the same fleet host.
5. Launch a fleet with the bot enabled
agents-fleet --telegramThe --telegram flag is opt-in: without it, no bot transport starts even if a token is configured. It is incompatible with -p / --prompt (single-shot mode), --no-coordinator, and --legacy-ui — agents-fleet refuses to start with a clear error if you combine them.
The fleet's REPL keeps running locally; meanwhile your phone receives dashboards, prompts, and confirmations.
6. Try it
From Telegram, send:
/tasksYou should get a formatted task breakdown back within a second.
Available commands
Every REPL slash command falls into one of four buckets, defined in src/bot/commandMatrix.ts. The matrix is the single source of truth — the colocated test enforces that every registered command has exactly one matrix entry, so this doc and the code can never drift.
| Bucket | Behavior over Telegram | Examples |
|---|---|---|
ui-only / unsupported | Rejected with an explanatory message — these only make sense in the Ink terminal. | /clear, /compact, /copy, /history, /scroll, /view |
informational | Runs immediately, no confirmation. Safe and read-only. | /tasks, /workers, /diagnose, /dod, /cost, /help, /loops, /stats, /sessions |
interactive | Single-tap inline-keyboard confirmation before running. Mutates fleet state but is recoverable. | /feature, /do, /research, /start, /loop-target, /init, /send, /commit, /code-review |
destructive | Three-tap confirmation. Session-ending or expensive. | /exit, /compete |
sec-gated | Supported but requires the operator to have already acknowledged a SEC class flag via --yolo --acknowledge-*. Currently no built-in command lives here; the bucket is reserved for future SEC-flagged commands. | — |
Synthesized workflow commands (auto-discovered from skills/crews) inherit the interactive + single-tap default policy.
Confirmation flow timing
- Single-tap confirmation: tap Confirm, command runs. Tap Cancel to abort. Buttons expire after 60s.
- Multi-tap (SEC / destructive): the button caption advances
Tap 1/3 → 2/3 → 3/3. All three taps must land within the same 60s window (seeMULTI_TAP_DEFAULT_TTL_MSinmultiTapConfirm.ts); wrong-nonce or expired taps are ignored.
Permission flows
When a worker hits a permission gate (file write, shell command, MCP tool call), the prompt fans out through Channel.askUser() and surfaces in Telegram as an inline-keyboard "choice ask". Implementation: src/bot/interactions.ts (InteractionRegistry).
Choice asks (inline buttons)
The bot renders one button per available decision. Each button carries a nonce'd callback_data payload so a stale tap from yesterday's prompt can never resolve today's. InteractionRegistry.handleCallback() matches the payload back to the pending promise.
Typical buttons for a file-write gate:
✅ Allow once
✅ Allow for this session
🔁 Allow forever (this scope)
❌ Deny once
❌ Deny allFreeform asks (typed replies)
Some prompts ask for a typed answer (e.g. "what should the worker do next?"). The bot listens for your next plain-text reply within DEFAULT_FREEFORM_REPLY_WINDOW_MS (60 s). If you don't reply in time the underlying promise rejects with a timeout so the worker never hangs.
rememberFor — make a decision stick
Permission decisions carry an optional rememberFor scope, tracked in src/bot/permissionMemory.ts:
| Scope | What it means |
|---|---|
this-request (default) | One-shot. The very next identical request will re-prompt. |
this-session | Remembered for the lifetime of the current paired session. Re-prompted on --resume. |
forever | Persisted for the scope key (e.g. write:src/). The bot won't ask again until you revoke. |
Destructive operations are clamped: supportsRememberFor() refuses to persist forever decisions for kinds classified as destructive, even if you tap "always". You will be re-asked.
Multi-tap SEC confirmation
SEC-class commands (anything in src/bot/commandMatrix.ts with support: 'sec-gated', plus any command with confirmation: 'multi-tap') require three taps of the same nonce'd button within 60 s. Logic lives in src/bot/multiTapConfirm.ts (MultiTapConfirmRegistry, generateMultiTapNonce, formatMultiTapCallbackData, parseMultiTapCallbackData).
Each tap updates the caption ("Tap 1/3 → 2/3 → 3/3"). The third tap resolves the gate. Two reasons this matters:
- Accidental fat-fingers can't trigger a destructive op from your pocket.
- A single intercepted callback isn't enough — an attacker who replays one captured
callback_dataonly gets one tap, not three.
Implicit yolo inheritance
You don't have to retype --yolo flags for every spawned worker. When a Telegram-triggered command would spawn a worker that needs elevated permissions, src/bot/implicitYolo.ts inspects the parent session's acknowledged SEC flags (YoloAckState):
getYoloRequirements(command)returns the set of acks the operation needs.hasYoloForOperation()checks the parent'sYoloAckState.commandSpawnsWorker()decides if inheritance applies at all.renderYoloShortage()formats a Telegram-friendly explanation when the parent's acks are insufficient — the operator sees exactly which--acknowledge-*flag is missing.
In practice: start the fleet once with --yolo --acknowledge-sec2 (or whichever combination matches your policy), and Telegram-triggered workers inherit those acks for the session.
Context-wipe warning
/compact, /clear, /reset, and --resume all reset the coordinator's working context. When invoked from Telegram, src/bot/contextWipeWarning.ts emits a prompt (buildContextWipeMessage) summarizing what's about to be discarded — capped at CONTEXT_WIPE_PROMPT_MAX_LEN = 500 characters so a runaway transcript can't blow up the Telegram message limit — so you can abort before losing work.
First-destructive-command warning
The first time a paired session runs a destructive command, src/bot/firstDestructiveWarning.ts (FirstDestructiveWarningState, renderFirstDestructiveWarning, formatYoloAckSummary) emits a one-time warning summarizing the active --yolo ack posture so you know what you're authorizing. Subsequent destructive commands run without re-warning in the same session.
Hang-detector advisory
The coordinator's hang watchdog (60 min hard threshold; see CoordinatorWatchdog) fires a T+5 min advisory over Telegram before the hard recovery kicks in. src/bot/hangWarning.ts (buildHangWarningMessage) formats the message with the stuck tool / turn so you can /diagnose from your phone and decide whether to wait it out or intervene.
Security model
Read this. The Telegram surface is only as secure as the token, the phone, and your understanding of these caveats.
The three things that grant full coordinator control
- The bot token (
AGENTS_FLEET_TELEGRAM_TOKEN). Anyone with the token can impersonate the bot from any host on the internet, replay history, and inject inbound messages on behalf of paired chats. Treat it like a database password. If it leaks, runagents-fleet bot rotate-tokenimmediately (see Recovery procedures). - A paired Telegram account. Anyone holding an unlocked phone signed into a paired Telegram account can drive your fleet — subject to the confirmation flows above. Multi-tap SEC raises the bar for destructive ops, but does not prevent a determined holder of the device from running them. If a paired device is lost or stolen,
agents-fleet bot revoke --user <fromId>cuts it off (see Recovery procedures). - Telegram itself. All inbound and outbound message contents traverse Telegram's servers. Don't paste secrets you wouldn't paste into a Telegram chat. The pairing token, bot token, and permission decisions are never logged in cleartext by agents-fleet; what is logged on Telegram's side is every command you type and every prompt the fleet sends.
What's protected, what isn't
| Protected | Not protected |
|---|---|
| Pairing tokens shown once, expire in 5 min, invalidated on re-issue. | Bot token in AGENTS_FLEET_TELEGRAM_TOKEN — operator responsibility. |
| Per-chat allowlist enforced on every inbound update. Unpaired chats get a generic refusal. | The Telegram account itself (2FA / device lock is on you). |
bot status redacts every secret, including chat ids longer than 8 chars (masked as 1234***5678). | Conversation contents — Telegram sees everything. |
| Destructive ops require 3 taps of a nonce'd button within 60 s. | A holder of the unlocked device who taps 3× in 60 s. |
Permission forever decisions clamped for destructive kinds. | Permission forever decisions for non-destructive kinds — you authorized them, agents-fleet trusts you. |
Outbound 429s honour retry_after; ledger dedupes on retry. | Telegram-side rate-limit policy changes — agents-fleet adapts but isn't immune. |
For the full risk catalogue with accepted mitigations and residual hazards, see docs/telegram-threat-model.md.
Recovery procedures
Stop the bot without killing the fleet
agents-fleet bot killBumps the controlGeneration counter on disk. The running bot detects the bump on its next poll cycle and shuts down the transport cleanly. The fleet itself keeps running headless in the REPL.
Rotate the bot token (suspected compromise)
If the bot token may have leaked (.bash_history, screen share, log dump, etc.):
- Talk to
@BotFather→/revoke→ confirm. This invalidates the old token immediately. BotFather issues a replacement. - Update the env var:bash
export AGENTS_FLEET_TELEGRAM_TOKEN="<new-token>" - Wipe paired chats + bump the control generation so any running bot stops:bashOutput reminds you to re-export
agents-fleet bot rotate-tokenAGENTS_FLEET_TELEGRAM_TOKENand runagents-fleet bot pairto re-pair each device. - Re-pair each device from scratch (
bot pair→ DM/start <token>).
Revoke a single paired device
You can see the list (masked, share-safe):
agents-fleet bot listThen revoke by chatId:
agents-fleet bot revoke 1234***5678(Pass the full chatId, not the masked form — bot list shows the mask for display, but the underlying chatId is what revoke accepts. The unmasked id is visible in telegram.json under ~/.fleet/.)
Revoke ALL devices for a single Telegram user
If a single operator's account is compromised but other paired operators should keep working:
agents-fleet bot revoke --user <fromId>Bulk-removes every pairing belonging to that Telegram fromId across all chats. Use bot list to find the masked fromId; the unmasked one lives in telegram.json.
Check status without leaking anything
agents-fleet bot statusAllow-listed output only: token-configured boolean, pairing count, whether a pending pairing token exists and is expired, and the controlGeneration counter. Never the bot token, chatIds, fromIds, labels, timestamps, or the pending pairing token itself.
Troubleshooting
--telegram flag is rejected at startup
The flag is incompatible with --legacy-ui, -p / --prompt, and --no-coordinator. Remove the conflicting flag. Single-shot mode (-p) is a non-interactive launch and has nothing for the bot to steer.
Bot starts but messages aren't received
- Check
agents-fleet bot status— isPairings: 0? Re-pair. - Check
AGENTS_FLEET_TELEGRAM_TOKENis exported in the same shell that ranagents-fleet --telegram. The bot reads it at startup. - Confirm you're DMing the right bot (the one whose token you exported, not another one you created earlier).
- Run
/diagnosefrom the REPL — the Telegram section will show whether the transport polled successfully.
401 auth-failed in logs
The bot token is invalid or has been revoked at BotFather. Get a new token, re-export, and run agents-fleet bot rotate-token.
409 Conflict in logs
Another process is running getUpdates on the same token (e.g. you have two agents-fleet hosts sharing one bot, or a leftover bot from a prior run). Telegram only allows one long-poller per token. Kill the duplicate (agents-fleet bot kill on the other host) or rotate the token.
Constant 429 Too Many Requests
The outbound queue honours Telegram's retry_after automatically (see src/bot/outboundQueue.ts, src/bot/outboundErrors.test.ts). Sustained 429s usually mean one of:
- A runaway loop is broadcasting every second — pause it from Telegram with
/loopsthen/stop. - You're using
--mute offfrom/muteoverrides during a token-heavy feature run — re-enable muting for chatty channels. - Per-chat rate is at the conservative default (1 msg/sec). Override in config if you have a real reason — but the default matches Telegram's hard ceiling for edits.
"Context wipe" warning showed up before I expected it
/compact, /clear, /reset, and --resume all trigger the warning even when the underlying op might not actually discard much. The truncation cap (CONTEXT_WIPE_PROMPT_MAX_LEN = 500 chars) means the summary may look shorter than the real working set — that's by design to fit a Telegram message. Tap "Cancel" if you weren't expecting it.
Pairing token expired before I sent /start
Pairing tokens are valid for 5 minutes. Run agents-fleet bot pair again — the prior pending token is invalidated as soon as the new one is issued.
"This chat is already paired to a different Telegram account"
The chatId you're DMing from is already pinned to another fromId. Decide which operator should keep it, then on the fleet host run agents-fleet bot revoke <chatId> and re-pair from the intended account.
Inline keyboard buttons do nothing
Each button carries a nonce'd payload. If the bot restarted between sending the prompt and your tap, the nonce is gone and the tap is ignored — by design. The originating prompt will time out and the worker will retry or ask again.
Reference: M2–M4 components on disk
| Concern | Source | Tests |
|---|---|---|
--telegram flag wiring | src/cli/telegramFlag.ts, src/index.ts | src/cli/telegramFlag.test.ts |
CLI subcommands (bot pair / status / list / revoke / kill / rotate-token) | src/commands/botCommand.ts | botCommand.test.ts, botStatus.test.ts |
| Pairing | src/bot/pairing.ts, src/bot/auth.ts, src/bot/storage.ts | pairing.test.ts, pairing.integration.test.ts |
| Transport / long-poll | src/bot/transport.ts | transport.test.ts |
| Channel orchestrator | src/channels/TelegramChannel.ts | TelegramChannel.*.test.ts |
| Egress pipeline + chunking | src/bot/egressPipeline.ts | unit tests in src/bot/ |
| Outbound queue + ledger | src/bot/outboundQueue.ts, src/bot/outboundLedger.ts | outboundQueue.test.ts, outboundLedger.test.ts, outboundErrors.test.ts |
| Command matrix (SEC gating) | src/bot/commandMatrix.ts | commandMatrix.test.ts |
| askUser fan-out | src/bot/interactions.ts | interactions.test.ts, TelegramChannel.ask.*.test.ts |
| Permission memory | src/bot/permissionMemory.ts | colocated tests |
| Multi-tap SEC | src/bot/multiTapConfirm.ts | multiTapConfirm.test.ts |
| Implicit yolo | src/bot/implicitYolo.ts | implicitYolo.test.ts |
| Context-wipe | src/bot/contextWipeWarning.ts | contextWipeWarning.test.ts |
| Hang warning | src/bot/hangWarning.ts | hangWarning.test.ts |
| First destructive | src/bot/firstDestructiveWarning.ts | firstDestructiveWarning.test.ts |
| M4 doc-sync audit | — | src/bot/m4.audit.test.ts |
| End-to-end harness | tests/e2e/helpers/telegramMock.ts | tests/e2e/scenarios/telegram-*.e2e.test.ts |
The audit test (m4.audit.test.ts) reads each of these files and asserts the key symbols are present and asserts this doc mentions every M4 surface by filename — so a refactor that accidentally drops a piece of M4 or lets the doc go stale fails CI rather than silently regressing the user-visible behaviour.
See also
docs/telegram-threat-model.md— formal threat model and residual-risk register.docs/security.md— broader agents-fleet security posture,--yoloacknowledgement flags, SEC classes.docs/yolo-migration.md— migrating from the pre-v0.25.0 unrestricted--yolobehaviour.src/bot/commandMatrix.ts— authoritative list of every command's Telegram support and confirmation policy.