Your Claude Code sessions can page you
A coding agent that runs for twenty minutes has a failure mode nobody designed for: it finishes, or it stalls on a question, and you are in another room. The work is done and sitting there, or it is not done and sitting there, and you cannot tell which without walking back to the machine.
Telechat's Desktop bridge exists for that gap. It hooks into Claude Code on your own machine and turns each of those moments into a card on Telegram — one you can read at a glance and reply to, with the reply landing back in the same session. This post is how it works, including the parts that were not obvious until they broke.
The hook surface
Claude Code fires hooks at defined points in a session and reads the hook's stdout for a structured response. Telechat registers four of them in ~/.claude/settings.json:
$ telechat bridge install --approval
✓ Registered hooks in ~/.claude/settings.json
Stop → telechat bridge notify Stop
Notification → telechat bridge notify Notification
SubagentStop → telechat bridge notify SubagentStop
PreToolUse → telechat bridge approve
Three of them are notifications: the session finished a turn, the session wants your attention, a subagent finished. The fourth is different in kind — PreToolUse runs before a tool call and can block it, which is what makes phone-side approval possible at all.
There is no daemon in this picture. The hooks are short-lived subprocesses; the already-running Telechat Telegram poller dispatches your replies and button taps back into the same module. If you already run Telechat for chat, the bridge costs you no additional process.
A wall of text is not a notification
The naive version posts the last assistant message to Telegram. We built that first. It is useless on a phone: the last message of a long agent turn is routinely two thousand characters of summary, and the one sentence that matters — the question it is waiting on — is somewhere in the middle of it.
So every notification is run through a fast model (Haiku) that produces a triage card instead:
💬 Reply from apprend-backend [b2ca0347]
⚠️ NEEDS DECISION
Migrated password hashing to argon2id, consolidated token
paths, added refresh rotation. All 31 tests pass, staging
verified.
⚠️ NEEDS YOU: Migrate OAuth1 to the new flow or drop legacy
support?
[📄 Full output] [💬 Use session]
Three things about this shape earned their place:
- Status first. ✅ DONE / ⚠️ NEEDS DECISION / ❌ BLOCKED / ℹ️ UPDATE, before any prose. Most cards you will read in under a second and not act on. That is the common case and it should be cheap.
- The decision is extracted, not buried. If the agent is asking you something, it is pulled to the bottom under its own marker. This is the entire point: the card exists so a decision can be made from a phone.
- The full output is one tap away. 📄 Full output returns the complete untrimmed text, smart-chunked across messages or attached as a
.txtwhen it is genuinely huge. The digest never becomes the only copy — and if summarisation is unavailable for any reason, the bridge falls back to posting the full chunked text rather than posting nothing.
That last rule is worth stating as a principle: a summariser in a notification path must degrade to the raw thing, never to silence. A lossy notification you cannot expand is worse than no notification, because you will trust it.
The recursion that ate itself
Here is the bug that only exists because of how the pieces fit together.
The digest is produced by running claude. Claude Code fires the Stop hook when a session ends a turn. The digest's own session ends a turn. So the digest fires Stop, which calls telechat bridge notify Stop, which produces a digest, which fires Stop.
The fix is four lines and an environment variable. The digest subprocess is spawned with TELECHAT_BRIDGE_INTERNAL=1, and both hook entry points check for it first:
def cli_notify(event: str) -> int:
# Recursion guard: the AI digest itself runs `claude`, whose Stop
# hook calls us again. The digest subprocess carries
# TELECHAT_BRIDGE_INTERNAL=1 → swallow that notification.
if os.environ.get("TELECHAT_BRIDGE_INTERNAL"):
return 0
...
The same guard sits in the approval path, for a sharper reason: without it, the digest's own tool calls would prompt you for approval — the machinery that tells you about a decision would start asking permission to tell you.
The general shape here is one to watch for. Any time a tool observes an event class and then performs an action in that same class, it can observe itself. Environment-variable tainting of the child process is the cheapest reliable break, because it survives whatever subprocess layering sits in between.
Approving tool calls from your phone
With --approval, the PreToolUse hook intercepts every Bash, Write and Edit call, posts a card with Approve and Deny buttons, and blocks until you tap. Claude Code respects the returned decision.
Blocking raises the obvious question: what happens when nobody taps? The honest answer for a personal tool is that you are usually sitting at the machine, so the historical behaviour — hand the decision back to Claude Code's normal permission flow, which prompts at the desktop — is right most of the time.
But it is exactly wrong for the person who turned approval on because they are away. So the timeout became a policy instead of an accident:
BRIDGE_APPROVAL_TIMEOUT=300 # seconds to wait for a tap
BRIDGE_APPROVAL_TIMEOUT_ACTION=fallthrough # fallthrough | deny | allow
fallthrough— hand it back to Claude Code's normal permission flow. The default.deny— refuse the call. Claude Code is told the reason, naming this setting, so the transcript explains itself later.allow— permit it. Only sensible with a short timeout on a machine you trust.
Two details matter more than the options themselves. First, the card states which one it will do — "Auto-denies in 5 min" — so the policy is visible at the exact moment you would act on it, rather than living in a config file you last read in March. Second, an unrecognised value means fallthrough: a typo cannot silently become a security posture.
Approval is off by default, per project. You opt in by replying /desktop_approve_on to a session card. A global "approve everything from my phone" default would be the wrong shape for a tool that can run rm.
Replying into a live session
A reply is injected with claude --resume against that session id. Headless resume needs a long-lived OAuth token — created with claude setup-token and stored as CLAUDE_CODE_OAUTH_TOKEN — which is the single most common reason a first bridge install half-works. Replies fail with a 401 and nothing else looks wrong, so telechat bridge install checks for it in preflight alongside the Claude CLI and your Telegram credentials, and warns before you discover it at the worst moment.
From Telegram you get session control that mirrors what you would do at the keyboard:
/desktop # list running sessions, tap to select
/desktop_use b2ca0347 # switch by 8-char short id
/desktop_all <msg> # broadcast to every running session
Reply to a specific card and the message goes to that session. Pick a session once and plain text goes there without the reply gesture — which is the difference between a tool you use at a desk and one you use walking.
What it does not do
Being direct about the edges, because finding them yourself is worse:
claude --resumewants an idle session. Replying while that session is mid-turn is undefined behaviour, not a queued message.- Telegram only. The bridge is not wired into the WhatsApp or Slack adapters. Those carry the chat loop, not the bridge.
- macOS gets the persistent service.
telechat bridge installsets up launchd so the bot restarts at login and after a crash; elsewhere use--no-serviceand your own supervisor. - It inherits your trust. Anyone on the Telegram allowlist can inject turns into sessions running as you. That is the whole design, and it is why the allowlist should have exactly one entry.
Try it
If you already run Claude Code, the bridge is one command on top of a Telechat install, and it removes hooks as cleanly as it adds them with telechat bridge uninstall.
Get the bridge
Install Telechat, then hook up Claude Code.
npm install -g telechat && telechat init