Run your coding agents from your phone. ctm mirrors Claude Code, OpenCode and Codex sessions into Telegram — each session gets its own topic, you see what the agent is doing, and you can reply, answer its questions and approve its tool calls from either side. Install it and all three are mirrored; there is nothing to enable per host.
Supported platforms: Linux x64, Linux arm64, macOS ARM64, macOS Intel x64
curl -fsSL https://raw.githubusercontent.com/robertelee78/claude-telegram-mirror/master/install.sh | sh
ctm setupInstalls one binary to ~/.local/bin/ctm (macOS arm64/x64, Linux x64/arm64), adds it to
your PATH with tab completion, and walks you through creating the Telegram bot. Open a
new shell afterwards.
ctm update # upgrade to the latest release
ctm update --rollback # put the previous version backEvery download is verified before it is installed: sha256, ctm's release signature, and on macOS Apple's notarization. Details in ADR-020.
- Three hosts, one bridge, nothing to enable — Claude Code (hooks + tmux), OpenCode (a plugin ctm installs) and Codex (its app-server, plus hooks ctm installs). See Other agent hosts.
- A topic per session — including sub-agents, which report into their parent's topic instead of opening one of their own.
- Both directions — the agent's replies, tool calls and questions go out; your text, photos and files go in.
- Approvals from either surface — tool-permission prompts appear as inline buttons in Telegram and stay answerable at the terminal. Whichever answers first wins; the other side is retired.
- Multiple-choice questions on both surfaces — Claude's
AskUserQuestionrenders natively in the CLI and as buttons in Telegram. A Telegram answer drives that live widget, paced againstcapture-paneso a keystroke only fires once the expected screen has rendered (ADR-015). - Confirmed delivery — an injected message is verified to have left the composer, and the Enter is retried if the TUI swallowed it.
- Human-readable tool summaries — "Running tests" rather than "Bash", for 30+ command shapes, with a Details button that still works days later.
- Stop/interrupt —
stopsends Escape,killsends Ctrl-C,/abortends the session. - Self-updating —
ctm updateswaps the binary atomically and restarts the service;ctm doctor --fixreconciles hooks, service and hosts. - Multi-machine — one daemon and one bot per host, all posting into one supergroup.
- Built to fail visibly — bot tokens scrubbed from logs,
flock(2)against duplicate daemons, canonicalized transcript paths, Unicode-safe chunking, and a doctor that reports what it cannot verify rather than assuming.
# 1. Install
curl -fsSL https://raw.githubusercontent.com/robertelee78/claude-telegram-mirror/master/install.sh | sh
# 2. Create the bot and configure everything (interactive)
ctm setup
# 3. Start the daemon (or `ctm service install` to run it at login)
ctm startThen use your agents exactly as you already do:
tmux new -s claude && claude # Claude Code needs tmux, so replies can reach its pane
codex # mirrored as-is
opencode # mirrored as-isctm doctor reports what is wired, per host.
Anyone you add to this Telegram channel can drive your shell and approve tool calls. Treat the channel like a shared shell: only add people you would already trust with git-commit access. Semi-trusted or public channels are not supported — authorization is intentionally chat-level, not per-user. The setup wizard requires you to acknowledge this before writing any configuration.
If a trusted-user list is ever added, it will be a whitelist (and its inverse a blacklist).
# Setup & diagnostics
ctm setup # Interactive setup wizard
ctm doctor # Diagnose configuration issues
ctm doctor --fix # Auto-fix detected issues
# Daemon control
ctm start # Start daemon (foreground mode)
ctm stop # Stop running daemon
ctm stop --force # Force stop if graceful shutdown fails
ctm restart # Restart daemon
ctm status # Show daemon status, config, and hooks
ctm config --test # Test Telegram connection
ctm toggle # Toggle mirroring on/off
ctm toggle --on # Force mirroring ON
ctm toggle --off # Force mirroring OFF
# Hook management
ctm install-hooks # Install global hooks
ctm install-hooks -p # Install to current project's .claude/
ctm uninstall-hooks # Remove hooks
ctm hooks # Show hook status
# OS service management (optional, for auto-start on boot)
ctm service install # Install as systemd/launchd service
ctm service uninstall # Remove system service
ctm service start # Start via service manager (installs the unit if missing)
ctm service stop # Stop via service manager
ctm service restart # Restart via service manager
ctm service status # Show service status
# Updates and shell integration
ctm update # Update to the latest release, then restart the service
ctm update --check # Report the newest release without changing anything
ctm update --rollback # Put the previous binary back
ctm shell-setup # (Re)install PATH + completions; --remove undoes it
ctm completions zsh # Print a completion script (bash, zsh, fish)
# Housekeeping
ctm prune-topics --ledger --dry-run # Show topics whose session is over
ctm prune-topics --ledger # ...and delete themNote: ctm stop and ctm restart auto-detect whether the daemon is running directly or via a system service and use the appropriate method.
| Command | Action |
|---|---|
| Any text | Sent to the agent as input |
stop |
Interrupt the current turn (Escape on Claude Code, the host's own interrupt elsewhere) |
kill |
Abort harder (Ctrl-C on Claude Code, the host's abort elsewhere) |
cc <cmd> |
Send /<cmd> to the agent as a slash command |
/status |
Show active sessions and mirroring state |
/sessions |
List active sessions with age and project dir |
/rename <name> |
Rename the session and its topic |
/attach <id> |
Attach to a session for updates |
/detach |
Detach from current session |
/mute / /unmute |
Suppress/resume agent response notifications |
/toggle |
Toggle mirroring on/off |
/abort |
Abort the attached session |
/ping |
Measure round-trip latency |
/help |
Show all commands |
See docs/ARCHITECTURE.md for additional details and command aliases.
When an agent asks permission to run something, the request appears in Telegram with buttons — and stays answerable at the terminal. Whichever surface answers first wins, and the other is retired.
| Button | Action |
|---|---|
| Approve | Allow this tool call |
| Reject | Deny this tool call |
| Abort | Stop the session |
| Details | Show the full tool input |
Details is recorded in the database and answerable for seven days, so it still works when you open Telegram later, or after the daemon has restarted.
Per host:
- Claude Code — the
PreToolUsehook waits up to five minutes for a Telegram answer, then falls back to the CLI prompt. Nothing appears under--dangerously-skip-permissions, because nothing asks. - Codex — approvals arrive over its app-server as requests carrying ids, so a Telegram tap resolves exactly that request. ctm's shell integration is what puts a session there (see Other agent hosts).
- OpenCode — answered through its API; a reply from either side dismisses the other.
One binary in three modes: the daemon, the hook the agents invoke (ctm hook,
ctm codex-hook), and the CLI you type. Everything downstream of a BridgeMessage is
host-neutral — the hosts differ only in how events get out and how your replies get in.
┌──────────────┐ Bot API ┌──────────────┐
│ ctm daemon │ ◀──────────▶ │ Telegram │
│ (tokio loop) │ long poll │ forum topics │
└──────┬───────┘ └──────────────┘
│
events up ▲ │ ▼ replies, approvals, answers
│ │
┌───────────┴───┴────────────┐
│ NDJSON over Unix socket │
└───┬──────────┬─────────┬───┘
│ │ │
┌────────────┴──┐ ┌─────┴──────┐ ┌┴─────────────┐
│ Claude Code │ │ OpenCode │ │ Codex │
├───────────────┤ ├────────────┤ ├──────────────┤
│ out: hooks │ │ out + in: │ │ out: hooks │
│ in: tmux │ │ the plugin │ │ in+approval: │
│ send-keys, │ │ ctm writes │ │ app-server │
│ verified │ │ (bus up, │ │ (requests │
│ submitted │ │ API in) │ │ carry ids) │
└───────────────┘ └────────────┘ └──────────────┘
What ctm installs, and keeps current:
| Host | Outbound | Inbound & approvals | ctm writes |
|---|---|---|---|
| Claude Code | PreToolUse / PostToolUse / Stop / … hooks |
tmux send-keys into the live pane |
hooks in ~/.claude/settings.json |
| OpenCode | the plugin's event hook, piped up a Unix socket | the same plugin, through OpenCode's in-process API | ~/.config/opencode/plugins/ctm.js |
| Codex | Codex's own hooks | its app-server (turn/start, turn/steer, approval requests) |
~/.codex/hooks.json, plus a codex shell function |
The daemon keeps all of that in repair: it rewrites the OpenCode plugin and the Codex
hooks when they are missing or stale (so ctm update rolls them forward), trusts the
Codex hooks with the hash Codex itself reports, and starts Codex's app-server if it is not
already running.
Failing closed beats guessing. The daemon resolves a Claude session's tmux pane from its cache, then SQLite, and gives up rather than picking one (ROUTING-001) — a guessed pane means keystrokes in someone else's session. The same instinct governs the rest: a Codex approval is answered by its request id, never by typing at a screen ctm cannot verify.
When you run agents on more than one machine, each machine needs its own bot: Telegram allows only one polling connection per bot token (error 409).
The model:
- One daemon per host - Each machine runs its own bridge daemon
- One bot per daemon - Each daemon uses a unique Telegram bot
- Multiple sessions per host - One daemon handles every session on that machine, across all three agents
- Shared supergroup - All bots post to the same Telegram supergroup
- Create one bot per system via @BotFather
- Add all bots to the same supergroup with admin permissions
- Configure each system with its own bot token:
# On System A (~/.telegram-env) export TELEGRAM_BOT_TOKEN="token-for-system-a-bot" export TELEGRAM_CHAT_ID="-100shared-group-id" # On System B (~/.telegram-env) export TELEGRAM_BOT_TOKEN="token-for-system-b-bot" export TELEGRAM_CHAT_ID="-100shared-group-id" # Same group!
- Each daemon creates topics for its own sessions - Replies route correctly because a daemon only processes the topics it created.
- A Telegram account and a supergroup with Topics enabled —
ctm setupwalks you through creating the bot and finding the chat id - At least one agent: Claude Code, OpenCode or Codex
- tmux, for Claude Code only: its replies are typed into the pane. OpenCode and Codex are driven through their own APIs and need no tmux.
ssh-keygen(OpenSSH ≥ 8.1 — part of every Linux and macOS since 2019), which the installer uses to verify the release signature.ctm updateverifies it itself.
- Message @BotFather ->
/newbot - Choose name and username (must end in
bot) - Save the API token
- Create a new group in Telegram
- Add your bot to the group
- Group Settings -> Enable Topics
- Group Settings -> Administrators -> Add your bot
- Enable: Manage Topics, Post Messages
- Send any message in the group
- Visit
https://api.telegram.org/botYOUR_TOKEN/getUpdates - Copy the chat ID (supergroups start with
-100)
- @BotFather ->
/mybots-> Select bot - Bot Settings -> Group Privacy -> Turn off
Create ~/.telegram-env:
export TELEGRAM_BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
export TELEGRAM_CHAT_ID="-1001234567890"
export TELEGRAM_MIRROR=true
# Optional:
# export TELEGRAM_MIRROR_VERBOSE=true
# export TELEGRAM_BRIDGE_SOCKET=~/.config/claude-telegram-mirror/bridge.sock
# export TELEGRAM_STALE_SESSION_TIMEOUT_HOURS=72 # Auto-cleanup dead sessions (default: 72)
# Hosts are on by default; these turn pieces off:
# export CTM_CODEX_ENABLED=0 # do not mirror Codex
# export CTM_OPENCODE_ENABLED=0 # do not mirror OpenCode
# export CTM_CODEX_REMOTE=0 # keep `codex` plain (loses Telegram approvals)Source in your shell profile (~/.bashrc or ~/.zshrc):
[[ -f ~/.telegram-env ]] && source ~/.telegram-envThe ctm setup wizard creates ~/.config/claude-telegram-mirror/config.json:
{
"botToken": "your-token",
"chatId": -1001234567890,
"enabled": true,
"verbose": true
}Environment variables take precedence over config file values.
Hosts need no configuration — they are on when the agent is installed. To turn one off, or to point ctm at an OpenCode server it could not otherwise reach:
{
"hosts": {
"codex": { "enabled": false },
"opencode": { "enabled": true, "baseUrl": "http://127.0.0.1:4096", "password": "…" }
}
}ctm doctor
# Checks: config, hooks, socket, tmux, systemd/launchd, Telegram API, hostsctm mirrors OpenCode and Codex sessions alongside Claude Code (ADR-016), and
it is on by default — there is nothing to enable. Install ctm, then run claude,
codex or opencode as you always do. Neither host needs tmux, a port, a password or
a special flag: the same Telegram UI — approvals, multiple-choice questions, replies,
stop/kill, /rename — works unchanged. Answering from Telegram dismisses the
prompt in the terminal, and answering at the terminal retires the Telegram keyboard
(both verified live against each binary).
How the daemon wires each host, automatically, at start and re-checked every minute:
- OpenCode — ctm drops one plugin file into OpenCode's global config
(
~/.config/opencode/plugins/ctm.js, honouringXDG_CONFIG_HOME), the same way it installs hooks into Claude Code'ssettings.json. Every OpenCode process loads it; it pipes OpenCode's event bus to the daemon over a local socket and runs the daemon's replies through OpenCode's own in-process API. A bareopencodetherefore opens no network listener at all. The file is generated and regenerated by ctm (ctm updaterolls it forward;ctm doctor --fixrewrites it) — don't edit it. - Codex — three pieces, all automatic:
- Outbound: ctm adds its own entries to
~/.codex/hooks.json, merging with any hooks you already have, and trusts them using the hash Codex itself reports — so there is no "Hooks need review" prompt for you to answer. - Inbound: ctm keeps Codex's app-server running (
codex app-server daemon start, idempotent) using Codex's native binary, and acodexstarted while it runs joins it. - Approvals: ctm's shell block defines a
codexfunction that hands the bare TUI,codex resume …andcodex fork …toctm codex-launch, which runs your session in that app-server — where each approval is a request with an id ctm can resolve from Telegram. A resume there would ignore-Cand refuse permission flags, so the launcher applies them to the thread first and tells you what it applied (codex --dangerously-bypass-approvals-and-sandbox resume <id>from a worktree resumes it there with full access). Every other subcommand and any explicit--remoterun as typed; without a daemon it runs codex locally and says so;CTM_CODEX_REMOTE=0turns it off. Runtype codexto see it.
- Outbound: ctm adds its own entries to
ctm doctor check 12/13 "Hosts" shows what was detected and whether it is wired; a
host that is not installed is simply reported as such and watched for.
Turning a host off is a one-line config change or an environment variable — see Configuration.
Also mirroring an external opencode serve over HTTP (optional)
The plugin covers every OpenCode process on this machine. For a server the plugin
cannot reach — another user's, or one in a container — the daemon can additionally
observe it over HTTP. It needs the explicit port (there is no port discovery) and the
server password (without one the server exposes /pty and shell endpoints to any local
process; ctm doctor makes a missing password a hard failure):
{ "hosts": { "opencode": { "baseUrl": "http://127.0.0.1:4096", "password": "choose-a-secret" } } }OPENCODE_SERVER_PASSWORD='choose-a-secret' opencode serve --port 4096The daemon runs under launchd/systemd and does not see your shell, which is why the
password lives in config.json (mode 0600, like the bot token); if
OPENCODE_SERVER_PASSWORD is set in the daemon's environment it takes precedence.
Host limits, honestly reported by ctm doctor:
- Codex sessions started while the ctm daemon is down are mirrored out but their
approvals must be answered at the terminal. ctm's shell block makes
codexjoin the app-server, which is what makes an approval answerable from Telegram (it carries a request id that ctm resolves atomically); without it there is no safe way to answer a prompt remotely, and ctm will not fake one by typing into your terminal. - Codex assistant text for such sessions arrives once per turn (its hooks expose the final message, not a stream); tool activity still appears as it happens. Sessions in app-server mode stream normally.
- Multiple-choice questions on Codex exist only in plan mode (
/plan), a Codex limit. - Neither host prints "answered from Telegram" in its own TUI after a remote decision — the prompt simply clears; ctm shows a toast on OpenCode and documents it on Codex.
If your project has .claude/settings.json with custom hooks, global hooks are ignored. Install hooks to the project:
cd /path/to/your/project
ctm install-hooks --project| Direction | Event | Display |
|---|---|---|
| CLI -> Telegram | User types | User (cli): ... |
| CLI -> Telegram | Tool starts | Running tests (summarized) |
| CLI -> Telegram | The agent responds | Claude / Codex / OpenCode: ... |
| CLI -> Telegram | Session starts | New Forum Topic created |
| CLI -> Telegram | Context compacting | Notification sent (Claude Code) |
| CLI <-> Telegram | AskUserQuestion | Buttons in Telegram, native widget in the CLI; a Telegram answer drives that widget with paced keystrokes (ADR-015) |
| Telegram -> CLI | User sends message | Typed into the pane (Claude Code) or sent over the host API (OpenCode, Codex), and confirmed delivered |
| Telegram -> CLI | User sends photo | Downloaded, path injected |
| Telegram -> CLI | User types "stop" | Escape (Claude Code) or an interrupt over the host API |
| Host -> Telegram | Sub-agent activity | Shown inside the parent session's topic, tagged with the agent |
- Binary: one native Rust executable (
ctm), ~10 MB, no runtime dependencies - State: SQLite at
~/.config/claude-telegram-mirror/sessions.db— sessions, the topic ledger, pending approvals, and the tool details behind the Details button (kept 7 days) - Sockets:
bridge.sock(hooks and host observers) andopencode.sock(the OpenCode plugin), both 0600 inside a 0700 directory - PID file:
bridge.pid,flock-guarded so two daemons cannot race - Downloads:
~/.config/claude-telegram-mirror/downloads/(0700) - Agent output: Claude's transcript
.jsonlon Stop; OpenCode and Codex report their own text over their APIs - Topic routing: each daemon only handles the topics it created, so several machines can share one supergroup
- Rate limiting: Governor-based, with a retry queue and exponential backoff
- Token scrubbing: every log line is filtered so a bot token cannot leak
- Tests: 894 passing across 49 source files and 13 integration test files. Eleven more
run only on request (
cargo test -- --ignored) because they drive the realcodex,opencodeand Claude Code binaries, a real tmux pane, or Apple's notary service.
Run the diagnostic tool first:
ctm doctor
ctm doctor --fix # Auto-fix common issuesHooks not firing?
- Check if project has local
.claude/settings.jsonoverriding globals - Run
ctm install-hooks -pfrom project directory - Restart Claude Code after installing hooks
409 Conflict error?
- Only one polling connection per bot token is allowed
- If running multiple systems, each needs its own bot (see Multi-System Architecture)
- Kill duplicate daemons:
ctm stop --force
Bridge not receiving events?
- Check socket:
ls -la ~/.config/claude-telegram-mirror/bridge.sock - Check daemon logs for errors
- Run
ctm statusto verify daemon is running
tmux injection not working? (Claude Code)
- Verify the session:
tmux list-sessions - Check daemon logs for "Session tmux target stored"
- A reply that lands in the composer but is never submitted was fixed in 0.2.44 — ctm now confirms the submit and retries the Enter
Codex: replies do not arrive, or approvals have no buttons?
ctm doctor(check 12/13) reports whether Codex's app-server is reachable and whether ctm's hooks are installed and trusted;ctm doctor --fixdoes both- Approvals require the session to live in the app-server, which ctm's shell block arranges
by adding
--remoteto a plaincodex. Open a new shell after installing or updating, and check withtype codex.CTM_CODEX_REMOTE=0turns it off - A session started while the daemon was down still mirrors out, but cannot be replied to
OpenCode: nothing mirrored?
- ctm installs
~/.config/opencode/plugins/ctm.js;ctm doctor --fixrewrites it - Plugins load at startup, so restart
opencodeafter installing ctm
Too many topics?
- Sub-agents stopped getting their own topics in 0.2.42 — update first
- Clear a backlog:
ctm prune-topics --ledger --dry-run, then without--dry-run
Messages going to wrong topic?
- Clear session DB:
rm ~/.config/claude-telegram-mirror/sessions.db
Service not starting (Linux)?
ctm service startinstalls the unit first if it is missing- Check status:
systemctl --user status claude-telegram-mirror - View logs:
journalctl --user -u claude-telegram-mirror -f Failed to connect to bus? A plain SSH login has no systemd user manager. Runsudo loginctl enable-linger $USERand log in again — that is also what keeps the daemon alive after you log out.
Service not starting (macOS)?
- Check status:
launchctl list | grep claude - View logs:
cat ~/Library/Logs/claude-telegram-mirror.*.log
Click to expand
For developers who want to build from source or contribute:
# 1. Clone and build
git clone https://github.com/robertelee78/claude-telegram-mirror.git
cd claude-telegram-mirror/rust-crates
cargo build --release
# Binary at: rust-crates/target/release/ctm
# 2. Run tests (894 of them)
cargo test
# ...and the end-to-end ones, which drive real codex/opencode/tmux binaries
cargo test -- --ignored
# 3. Use the binary directly
./target/release/ctm setup
./target/release/ctm startrust-crates/ctm/src/
main.rs · cli.rs # CLI entry point and command definitions (clap)
lib.rs # Library re-exports
hook.rs # Claude Code hook event processing
config.rs # Configuration (env > file > defaults), including hosts
error.rs · types.rs # Error types; wire types, validation, security constants
session.rs # SQLite: sessions, topic ledger, approvals, tool details
socket.rs # Unix socket server/client (flock, NDJSON)
injector.rs # tmux injection, with submit verification
formatting.rs # Formatting, chunking, ANSI stripping
summarize.rs # Tool action summarizer (30+ patterns)
liveness.rs # Pane/host liveness policy for topic reconciliation
prune.rs # prune-topics (host-aware liveness)
update.rs # Self-update: release record, verified download, atomic swap
apple_trust.rs # ADR-018: Developer ID pins + codesign/notarization verification
shell.rs # PATH, completions, and the `codex` remote-mode function
doctor.rs # Diagnostics with --fix
installer.rs # Claude Code hook installer
setup.rs # Interactive setup wizard
colors.rs # ANSI helpers
bot/ # Telegram API client (client, queue, types)
daemon/ # Event loop; socket/telegram/callback handlers; cleanup;
# reconcile; files; host_dispatch — the only host-aware seam
host/ # ADR-016 hosts:
# link observer <-> daemon socket link
# opencode translator + HTTP observer
# opencode_pipe plugin transport (a bare `opencode`)
# opencode_plugin the plugin ctm provisions
# codex app-server observer (JSON-RPC over WebSocket)
# codex_rpc one-shot RPC client (hooks/list, config write)
# codex_hooks the hooks ctm installs and trusts
# codex_hook_cmd `ctm codex-hook`, the forwarder
# codex_daemon keeps Codex's app-server alive
# detect finds host installs from a service PATH
service/ # systemd, launchd, env file
rust-crates/ctm/tests/ # 13 integration test files, including host_e2e.rs (real
# codex/opencode) and injector_tmux.rs (real tmux)
MIT
Built so a coding agent — Claude Code, OpenCode or Codex — can be watched and driven from a phone.