Skip to content

Telegram Remote Steering

For developers: see docs/telegram-architecture.md for the internal architecture and contribution guide.

Sending and receiving files: see docs/telegram-attachments.md for the inbound media + outbound send_attachment operator 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 timeQuick start
Know what commands are usable from TelegramAvailable commands
Understand permission prompts (allow / deny / always)Permission flows
Understand SEC three-tap confirmationsMulti-tap SEC confirmation
Read the security disclaimer (token = full control)Security model
Stop the bot or revoke a phoneRecovery procedures
Fix a flag/auth/rate-limit problemTroubleshooting

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

bash
# macOS / Linux
export AGENTS_FLEET_TELEGRAM_TOKEN="1234567890:AaBbCc-..."
powershell
# 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

bash
agents-fleet bot pair

Output (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

bash
agents-fleet --telegram

The --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:

/tasks

You 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.

BucketBehavior over TelegramExamples
ui-only / unsupportedRejected with an explanatory message — these only make sense in the Ink terminal./clear, /compact, /copy, /history, /scroll, /view
informationalRuns immediately, no confirmation. Safe and read-only./tasks, /workers, /diagnose, /dod, /cost, /help, /loops, /stats, /sessions
interactiveSingle-tap inline-keyboard confirmation before running. Mutates fleet state but is recoverable./feature, /do, /research, /start, /loop-target, /init, /send, /commit, /code-review
destructiveThree-tap confirmation. Session-ending or expensive./exit, /compete
sec-gatedSupported 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 (see MULTI_TAP_DEFAULT_TTL_MS in multiTapConfirm.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 all

Freeform 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:

ScopeWhat it means
this-request (default)One-shot. The very next identical request will re-prompt.
this-sessionRemembered for the lifetime of the current paired session. Re-prompted on --resume.
foreverPersisted 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:

  1. Accidental fat-fingers can't trigger a destructive op from your pocket.
  2. A single intercepted callback isn't enough — an attacker who replays one captured callback_data only 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's YoloAckState.
  • 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

  1. 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, run agents-fleet bot rotate-token immediately (see Recovery procedures).
  2. 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).
  3. 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

ProtectedNot 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

bash
agents-fleet bot kill

Bumps 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.):

  1. Talk to @BotFather/revoke → confirm. This invalidates the old token immediately. BotFather issues a replacement.
  2. Update the env var:
    bash
    export AGENTS_FLEET_TELEGRAM_TOKEN="<new-token>"
  3. Wipe paired chats + bump the control generation so any running bot stops:
    bash
    agents-fleet bot rotate-token
    Output reminds you to re-export AGENTS_FLEET_TELEGRAM_TOKEN and run agents-fleet bot pair to re-pair each device.
  4. Re-pair each device from scratch (bot pair → DM /start <token>).

Revoke a single paired device

You can see the list (masked, share-safe):

bash
agents-fleet bot list

Then revoke by chatId:

bash
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:

bash
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

bash
agents-fleet bot status

Allow-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

  1. Check agents-fleet bot status — is Pairings: 0? Re-pair.
  2. Check AGENTS_FLEET_TELEGRAM_TOKEN is exported in the same shell that ran agents-fleet --telegram. The bot reads it at startup.
  3. Confirm you're DMing the right bot (the one whose token you exported, not another one you created earlier).
  4. Run /diagnose from 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 /loops then /stop.
  • You're using --mute off from /mute overrides 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

ConcernSourceTests
--telegram flag wiringsrc/cli/telegramFlag.ts, src/index.tssrc/cli/telegramFlag.test.ts
CLI subcommands (bot pair / status / list / revoke / kill / rotate-token)src/commands/botCommand.tsbotCommand.test.ts, botStatus.test.ts
Pairingsrc/bot/pairing.ts, src/bot/auth.ts, src/bot/storage.tspairing.test.ts, pairing.integration.test.ts
Transport / long-pollsrc/bot/transport.tstransport.test.ts
Channel orchestratorsrc/channels/TelegramChannel.tsTelegramChannel.*.test.ts
Egress pipeline + chunkingsrc/bot/egressPipeline.tsunit tests in src/bot/
Outbound queue + ledgersrc/bot/outboundQueue.ts, src/bot/outboundLedger.tsoutboundQueue.test.ts, outboundLedger.test.ts, outboundErrors.test.ts
Command matrix (SEC gating)src/bot/commandMatrix.tscommandMatrix.test.ts
askUser fan-outsrc/bot/interactions.tsinteractions.test.ts, TelegramChannel.ask.*.test.ts
Permission memorysrc/bot/permissionMemory.tscolocated tests
Multi-tap SECsrc/bot/multiTapConfirm.tsmultiTapConfirm.test.ts
Implicit yolosrc/bot/implicitYolo.tsimplicitYolo.test.ts
Context-wipesrc/bot/contextWipeWarning.tscontextWipeWarning.test.ts
Hang warningsrc/bot/hangWarning.tshangWarning.test.ts
First destructivesrc/bot/firstDestructiveWarning.tsfirstDestructiveWarning.test.ts
M4 doc-sync auditsrc/bot/m4.audit.test.ts
End-to-end harnesstests/e2e/helpers/telegramMock.tstests/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