Telegram remote bridge — adopter guide
Control Claude Code from your phone over Telegram: DM the bot, an always-on bridge spawns a bounded Claude run per message and relays the reply back.
What it is
An always-on bun process (scripts/telegram/supervisor.ts +
scripts/telegram/poller.ts) owns the single Telegram poll slot for your bot
token. For each inbound message it spawns a short-lived, bounded claude run
that does one turn and exits; a file-backed bus (under
~/.claude/handover/bridge/) carries per-session state across runs, usage
caps, and crashes. There is no long-lived Claude session to babysit; the
bridge itself is just the bun process.
Mechanism detail (delivery model, file bus layout, IPC, hardening) lives in
docs/internals/telegram-bridge.md; this page
is the front door.
Required config
~/.claude/channels/telegram/.envwithTELEGRAM_BOT_TOKEN=<your bot token>~/.claude/channels/telegram/access.jsonwith{"allowFrom":["<your-telegram-user-id>"]}
Start / stop
Start:
cd scripts/telegram && bun supervisor.tsThe supervisor keeps bun poller.ts alive (restart-on-exit with backoff).
Stop (cross-platform):
cd scripts/telegram && bun supervisor.ts --killWhat you can do from a phone
DM the bot once it’s running:
work on <TICKET-KEY>: dispatch: create/resume a session for that ticket and run it.<TICKET-KEY>: <text>: send a follow-up to that session.status/sessions/stop <TICKET-KEY>: control commands.- Anything else: ordinary chat with the current session.
The bot also answers three Telegram slash commands, in a DM only (it stays silent in groups so pairing codes never leak to other members):
/start: pairing instructions./help: what the bot does and the two commands below./status: your pairing state: paired (and as whom), a pending code to approve with/telegram:access pair <code>in Claude Code, or not paired.
Privileged actions (arming a resume, restarting the bridge, and other
operator-only ops) are gated behind an explicit auto-command syntax and an
allowlisted-operator check; see the auto-action docs in
internals/telegram-bridge.md
for what’s enabled and how it’s authorized. A forwarded message can never
trigger one of these commands.
Messaging a running chief
/chief <text> delivers to the one live chief, without needing its
session name. Live means its waiter heartbeat is waiting or sampling and
less than five minutes old. With none live, nothing is queued; with several,
the bridge lists them and asks you to choose /chief <session-name> <text>.
The first word names a chief only when that inbox already exists; otherwise
it is part of the bare text. /chiefs lists live names, heartbeat timestamps,
and bucket/project (older chiefs without launch metadata show unknown).
The first word also selects a chief by a unique fragment of its name, and a
message to an armed chief that is busy is queued rather than dropped. /chief, the fleet verbs and /lockdown accept any letter
case and the /verb@botname form; the auto ops stay
strict. The older /console and /consoles still work as aliases of /chief and /chiefs.
Each chief’s first waiter arm queues a DM: “Chief <name> is live — just send /chief <text>”. A successor announces its new identity on its first arm; restarting the same waiter does not repeat the announcement. Replying to an announcement, chief acknowledgement, or chief answer routes to that chief using the bridge’s saved chat/message-id receipt, not quoted text. Explicit slash/control commands keep their own routing when sent as replies.
Neither form starts a session. The bridge appends one line to that
chief’s inbox file, <bridge root>/consoles/<session-name>.md (default
~/.claude/handover/bridge/consoles/), and the chief watches the file with
the event waiter started in its ACTION ZERO (chief-kit/chief-wait.sh,
which reads it through inbox-follow.sh --once and a read cursor beside the
file, so a line is never lost across a re-start).
- Who: only the
allowFromoperator, in an allowed chat, with a typed (not forwarded, not captioned) message. Anyone else’s/chief …is ordinary chat, exactly as before.access.jsonremains the only sender gate. - Ack:
→ chief <name>means the line was queued, not that the chief has read it. A response with no live chief, or an ambiguous one, queues nothing; delivery never creates a chief inbox. - Reply: the chief answers through the same outbox as every other bridge
reply (
bun scripts/telegram/chief-route.ts reply <chat_id> <text>); the running poller sends it. On Linux the CLI resolves the calling session name automatically when its inbox exists. For an explicit identity (including other platforms), usereply --chief <session-name> <chat_id> <text>. - Authority: the line carries your authority (a ruling, a halt or new
work), as if typed in the chief’s terminal. It never changes the chief’s
permissions or settings and never bypasses
merge-on-green.sh’s own GO verification. - A wrapped chief leaves its file behind, so a line to one is acked but read by no one; check the chief is live before relying on it.
Slash chief requests and emergency lockdown
These slash forms only carry the same authority as free /chief text:
| Command | Queued chief request |
|---|---|
/fleet | fleet status |
/workers | list workers |
/go <worker> | ask whether GO is pending; never write GO or a grant |
/push <worker> | request a push through the chief’s normal gates |
/halt [<worker>] | request a halt of that worker, or the batch |
Worker labels are 1–64 ASCII letters, digits, underscores, dots or hyphens; ..,
path separators and controls refuse the command shape. A reserved verb
(/go, /push, /halt, /fleet, /workers) with any other shape (bad,
oversized or traversal label, control characters, extra arguments, a missing
label, wrong case) is a terminal refusal (“malformed fleet command —
nothing was queued”), never agent chat; a nonoperator’s malformed shape is
dropped silently. Bare fleet, legs,
go, push and halt (no slash) remain chat; existing status still reports bridge
session status. Messages must pass the existing operator/allowed-chat gate,
be typed, unforwarded and without a model: tag.
A trusted reply receipt selects its original chief only while fresh;
otherwise exactly one fresh chief must be live. Zero or multiple live
chiefs queue nothing. Reply to the chosen chief’s announcement or answer
to disambiguate. Unlike explicitly named /chief, these requests refuse
stale targets. Their acknowledgement says queued, never completed, and
replies thread to the inbound message while retaining the same chief receipt
owner. No handler launches a session, runs git, or grants approval.
An inbound command older than TELEGRAM_VERB_MAX_AGE_S (default 300 seconds)
is refused with “stale, resend”, preventing outage replay. Missing/invalid
message dates also refuse; up to five seconds of future clock skew is accepted.
Invalid/nonpositive configuration uses 300 seconds. The same window covers
free/named /chief, /chiefs, trusted chief reply threads and typed
auto-actions (/arm, /mergepub, /cr-grant-delta, /restart, the
break-glass ops and /confirm). /lockdown
remains narrowing-only even when replayed.
/lockdown sets <bridge root>/lockdown and confirms it. The operator may also
use /lockdown@<botname> or a leading model: tag: narrowing ignores that
routing hint, but still requires a typed, unforwarded operator message. A
noneligible chief/lockdown verb is dropped, never handed to an agent.
While any entry exists at the lockdown path (even a dangling symlink), the
bridge drops all Telegram agent dispatch for every sender: ordinary chat,
ticket work, followups, chief routes/receipt threads and all typed
auto-actions, including /restart. Station policy: only the operator is
told, by a reply of “locked, reset at the station”; every other sender is
blocked silently. A lockdown arriving in the final settlement gap before a
spawn also refuses it without any content-filter notice (no agent ran). Already-pending/coalesced/retry work cannot
spawn either. It does not cancel already-running actions. The flag survives
bridge restart. Reset at the station only, after securing the Telegram account:
rm -- "${BRIDGE_ROOT:-$HOME/.claude/handover/bridge}/lockdown"No remote unlock exists. End the compromised Telegram sessions from a second
trusted device. /launch-bypass-worker … is retired and always refuses, even if
explicitly enabled: start hook-bypass workers at the station. Telegram never ratifies
a hook-bypass launch.
Threat ceiling: a same-uid process can forge an inbox line or remove the
station-local flag. The flag is emergency narrowing, not a protected privilege
boundary. A queued command never carries more authority than free-text
/chief and never bypasses PR, automated review or GO gates. If the Telegram account is
taken over, /chief free text, /mergepub and /cr-grant-delta remain
one-factor operator authority until lockdown; this slice adds no approval
capability or second factor.
Break-glass ops. For an operator away from the station:
/station-status, /revert-main <pr>, /repin-hooks,
/launch-worker <N-label> [--hook-bypass], /cr-reset <pr>,
/close-wrapped [<N-label>], /relaunch-chief [<name>] and
/restart-bridge and /allow-rule <id>. Each is off until named individually in
TELEGRAM_AUTO_ACTIONS. Every op except /station-status runs only after the
operator sends back a one-time /confirm <code>, in the same chat within 5
minutes. Break-glass ops and their /confirm run only in the operator’s
private chat with the bot, never in a group.
/revert-mainreverts only the PR that is main’s current HEAD, and merges its revert with--admin: that is break-glass, and it is operator-initiated, confirm-coded and audited./launch-worker --hook-bypassnarrowly reverses the worker hook-bypass lockout. It exports onlyHIMMEL_HOOK_INTEGRITY_BYPASS_OK=1, and only for a sha256-recorded launcher; a manifest worker is refused with it.- Lockout recovery is
/revert-main <pr>then/repin-hooks. /allow-rule <id>adds one pre-reviewed permission rule for a command shape the classifier keeps denying. The rule text comes only from the checked-in registryscripts/telegram/allow-rules.json, never from the message; an unknown id is refused. It writes the primary checkout’s untracked.claude/settings.local.json(never the trackedsettings.json), is idempotent, and backs the old file up before writing.
Full table: scripts/telegram/README.md.
After deploying an updated bridge, restart it at the station on Linux:
bash scripts/telegram/restart-bridge.sh (do not start a second poller).
The one-poller-per-token trap
Telegram allows exactly one getUpdates consumer per bot token. Do not
launch a claude --channels (or TELEGRAM_OWN_POLLER=1) session by hand
while the bun bridge is running: it becomes a second consumer and Telegram
returns 409 Conflict, breaking delivery for both. If you need a manual
--channels session, stop the bridge first (bun supervisor.ts --kill).
Troubleshooting
409 Conflictin the poller log: another poller holds the token (usually a stray hand-launched--channelssession, or two supervisors). Kill the extra poller.- No reply to a DM: check that your Telegram user id is in
access.json’sallowFrom, and that the bridge process is actually up.