All articles

Building a Terminal Bridge: tmux, Telegram, and Claude Code

3 min read
claude-codetelegramtmuxsystems-architecture

Claude Code changed what's possible from a terminal, but it assumes you're sitting in front of one. I wanted to reach my shell — and Claude Code inside it — from my phone. Not a chatbot that runs commands on my behalf. An actual session I could pick up from Telegram while I'm out, with full tool-approval flow, and set down again at my desk without losing context.

So I built tele-gent. Every interesting problem in the project came down to the same question: what's the simplest thing I can read or write to make two interfaces share one session? The answer, every time, was a file.

Replacing a pty with a pipe

My first approach was direct: spawn a pseudo-terminal, read from the master fd, forward bytes to Telegram. I spent hours fighting it — terminal state management, resize events, interactive programs breaking in ways I couldn't reproduce. Every edge case in terminal emulation becomes your edge case.

The fix was one tmux command:

tmux pipe-pane -t $SESSION "cat >> $PIPE_FILE"

The bot tails that file, strips ANSI escapes, and sends cleaned text to Telegram. Input goes the other way via tmux send-keys. Hundreds of lines of pty management replaced by a file that grows and a process that reads it. No connection state, no serialization, nothing to reconnect.

Round-trip latency is 200–400ms, dominated by Telegram's API. Short output goes as a message; longer output chunks to Telegram's 4096-character limit with a /full command that sends the complete text as a file. You lose full-screen TUI rendering — pipe-pane captures bytes, not terminal state — but on a phone, you're issuing commands and reading results. That's the interface that matters.

Two views from one session

The pipe works for shell commands, but Claude Code's terminal output is a presentation layer — spinners, syntax highlighting, status bars. Forwarding that raw to Telegram means screen-scraping: fragile and unreadable.

The better data source was already on disk. Claude Code writes structured state to JSONL files in ~/.claude/projects/ — every message, tool call, and result as a JSON object. When the bot detects Claude Code is the foreground process in tmux, it switches from the terminal pipe to these JSONL files, bookmarking by UUID. When Claude Code exits, it switches back.

Same tmux session, two interfaces shaped to their medium:

  • Laptop: full terminal with syntax-highlighted diffs and raw output.
  • Phone: clean conversational stream — what Claude said, what it ran, what happened.

I use this daily. Start a task from my phone while I'm out, pick it up at my desk when I get home. Or the reverse — kick something off at my desk, monitor and approve from the couch. No reconnection step, because both sides read from the same session.

Permission approval without coordination

Claude Code asks permission before running tools — the right default, but useless if you can't reach the terminal. The --dangerously-skip-permissions flag is binary where you want something selective.

The design problem: how do you make two independent interfaces share an approval flow without a coordination protocol?

A Claude Code hook fires before each tool call and writes a JSON request to /tmp. The bot picks it up, sends inline buttons to Telegram. The same prompt appears in the local terminal. Whichever side responds first wins. If someone answers from the terminal, the bot detects the JSONL file growing while a request is pending and auto-clears the Telegram prompt. Two approval channels, zero conflicts — the filesystem is the only shared state.

The hook timeout is 24 hours. In practice, this means I can leave my desk, approve a few permission prompts from my phone as they come through over the next hour, and come back to finished work.

Security

tele-gent authenticates by Telegram user ID and runs under local Unix permissions. A compromised Telegram account means a shell with your uid, reachable from anywhere. For a personal dev machine, that's a risk I accept. For anything beyond that, run it behind a VPN under a scoped user account.

Code is at timstarkk/tele-gent.