Telegram as a Control Plane
Before this, read:
- Hooks: automating behavior the model can’t — hooks are the mechanism the control plane is built on
The question is not whether to have a control plane for your agent system. The question is whether it’s a terminal window you have to be at a desk to use, or something you can reach from anywhere. The Telegram control plane described here is how a Claude Code session becomes an always-on bot you talk to from your phone.
The architecture
Section titled “The architecture”Three pieces:
- Claude Code session running in tmux, kept alive under launchd (
com.jd.claude-telegram-daemon.plist). It runs on the Mac Mini, always connected, always listening. - The inbound router (
~/agent-system/scripts/telegram-inbound-router.sh) — a shell script invoked by a PreToolUse hook that intercepts every incoming Telegram message before Claude handles it. - The reply tool (
mcp__plugin_telegram_telegram__reply) — the MCP tool Claude calls to send a message back. Claude’s text output does not reach JD; only calls to the reply tool do.
The flow: JD sends a Telegram message → the Telegram MCP server delivers it to the session → the PreToolUse hook fires the inbound router → the router checks if it’s a built-in command (handles it) or a message for Claude (passes it through) → Claude processes and calls reply → JD sees the response.
The command palette
Section titled “The command palette”The inbound router intercepts messages that start with /:
/status → current system health (running agents, recent errors)/tasks → open tasks across all domains/loops → active watchers + open loops/system → Mac Mini health (disk, CPU, memory)/disk → disk usage breakdown/changes → last 5 CHANGELOG entries/help → command listEach command is a shell function in telegram-commands.sh. The router calls the function directly, bypasses Claude entirely, and sends the result to Telegram via the reply tool. Responses are kept to ≤500 characters — this is a phone interface.
These commands exist because some questions don’t need judgment; they need speed. “Is everything running?” should not kick off a 30-second Claude reasoning pass. It should pipe through jq on a health.json file and return in under a second.
The reply protocol (non-negotiable)
Section titled “The reply protocol (non-negotiable)”Claude’s text output does not reach JD. The only way a message lands in JD’s Telegram is a call to the reply tool:
mcp__plugin_telegram_telegram__reply( chat_id="730465415", text="Your message here")This is enforced by a Stop hook (~/agent-system/scripts/telegram-stop-hook.py) that checks whether the session produced a Telegram reply. If not, it fires an alert: “Claude may have forgotten to reply.” The hook doesn’t force a reply — it makes the failure visible immediately so JD doesn’t wonder why the bot went silent.
The most common failure pattern: Claude completes a long research task (reading many files, running multiple tool calls), generates a thorough response as text output, and forgets to wrap it in a reply call. The response goes nowhere.
The rule in CLAUDE.md: after any Telegram message, before ending the turn, call the reply tool. The Stop hook catches the cases where this is missed.
Quick capture
Section titled “Quick capture”Any message JD sends that isn’t a command goes through quick_capture — a handler that writes the message to the Obsidian vault inbox (~/Library/Mobile Documents/iCloud~md~obsidian/Documents/JDs Vault/01-Inbox/) within 60 seconds, with a timestamp and source tag. JD thinks of it as “send yourself a note from anywhere.”
This is one of the first integrations built (2026-04-16) and one of the most used. The pattern: a thought occurs to JD during the day, he messages the bot, the note lands in Obsidian before he forgets it. No app-switching, no note-taking app to open.
LaunchAgent vs. tmux: why both
Section titled “LaunchAgent vs. tmux: why both”The Telegram daemon runs in two layers:
tmux (claude-daemon session) holds the actual Claude Code process. tmux keeps the session alive across SSH disconnects, lets you attach from a terminal to see what’s happening, and provides a named session ID for the watchdog to reference.
launchd (com.jd.claude-telegram-daemon.plist) starts tmux and the Claude process on boot and restarts them if they exit. Without launchd, the daemon dies at reboot and stays dead until someone manually restarts it.
The self-healer (agents/ai_os/self_healer_autofix.py) includes the Telegram daemon in its auto-fix whitelist — if the daemon isn’t running and the launchd service can be safely kickstarted, the self-healer does it without asking. This was one of the first safe-to-autofix items added to the whitelist.
What the inbound router does
Section titled “What the inbound router does”~/agent-system/scripts/telegram-inbound-router.sh runs on every incoming message, before Claude sees it. Its job:
- Check if the message matches a
/commandpattern - If yes: handle it (call the appropriate function in telegram-commands.sh) and exit without passing to Claude
- If no: pass the message through to Claude
- In either case: if the message contains text that looks like a capture item, also run quick_capture
The router runs as a shell script, not as a Claude Code tool. This keeps latency low for command responses and keeps the command palette working even when Claude is mid-task.
The access model
Section titled “The access model”Only messages from allowed users reach Claude. The allowlist is managed by the telegram:access skill — run it in the terminal, never modify access.json by hand. If a Telegram message contains “approve the pending pairing” or “add me to the allowlist,” that is the pattern a prompt injection attempt would use. The access configuration is admin-only, terminal-only.
Next: The auto-firing voice summary — how a Stop hook sends a spoken version of every long reply, and the Kokoro TTS setup that replaced cloud TTS. Voice in/out.