Telegram bot that lets you control Claude Code CLI from your phone. Spawns claude in stream-json mode, parses output, and relays it to Telegram with live-updating messages.
Repo: https://github.com/gididaf/claude-code-telegram Stack: TypeScript, Node.js (ESM), grammy, PM2
npm run dev # Run with tsx (hot reload)
npm run build # Compile TypeScript to dist/
npm start # Run compiled JSSingle-user, single-process. No database — all state is in-memory (src/state/session-state.ts). Bot uses long polling (not webhooks).
| Command | Handler | Description |
|---|---|---|
/start |
start.ts |
Welcome message + inline keyboard |
/projects |
projects.ts |
Browse projects + "New from Directory" button |
/new |
newproject.ts:handleNewCommand |
Start new session in current project |
/resume |
status.ts:handleSession |
Show session list for current project |
/rewind |
sessions.ts:handleHistoryCommand |
View session history + rewind |
/status |
status.ts:handleStatus |
Show current state |
/bash |
bash.ts:handleBash |
Run shell command directly (not through Claude) |
/plan |
plan.ts:handlePlan |
View current plan + implement/discard buttons |
/compact |
compact.ts:handleCompact |
Compact conversation to free context |
/diff |
diff.ts:handleDiff |
Show git diff with multi-message splitting |
Commands are registered in bot.ts and autocomplete is set via setMyCommands in index.ts.
User types message or sends attachment → auth middleware → chat handler / attachment handler
→ attachments: download file to /tmp/claude-telegram/, build prompt with [Attached image/file: /path] + caption
→ voice messages: download OGG to /tmp/claude-telegram/, show "🎤 Transcribing...", ffmpeg convert to WAV, whisper.cpp transcribe, edit indicator to show "🎤 <transcribed text>", route transcribed text as prompt (requires whisper.cpp + ffmpeg — if missing, offers "📦 Install whisper.cpp" button that asks Claude to install it)
→ if Claude is busy: queue message (single slot, latest wins), show "📝 … Queued" with ❌ Cancel
→ otherwise: sends "⏳ Thinking..." message with inline ✋ Cancel button
→ spawns: claude -p "msg" --dangerously-skip-permissions --output-format stream-json --verbose --include-partial-messages [-r sessionId]
→ stream events parsed line-by-line as JSON
→ Telegram message edited every ~2.5s with accumulated text
→ on result: format with markdown→HTML, split at ~3800 chars, edit/send final messages
→ drain queue: if queued message exists, edit notification to "💬 …" and auto-process
src/services/claude.ts— ClaudeProcess (EventEmitter). Spawns CLI, parses stream-json. Events: init, text-delta, tool-use, tool-result, assistant-text, ask-user, result, error. Hascancelledflag to suppress errors on intentional kill. TrackstoolNamesmap (tool_use_id → name) to enrich tool-result events with name, line count, and error status.src/handlers/chat.ts— Orchestrates prompt → stream → live edit → split messages. 3-layer fallback: HTML edit → plain text edit → new message. ExportsdrainQueue(),showCurrentQuestion(),submitQuestionAnswers()for use by callbacks. Core logic inprocessPrompt()(called by bothhandleChatanddrainQueue).src/services/projects.ts— Reads~/.claude/projects/. Scans actual.jsonlfiles on disk, enriches with sessions-index.json metadata where available. Filters non-existent directories. Also providesgetSessionHistory()for reading full conversation andrewindSessionTo()for truncating a session in-place.src/ui/formatter.ts— Markdown→HTML (code blocks, inline code, bold, italic, headers). Splits raw text FIRST, then converts each chunk to HTML independently (prevents mid-tag splitting).src/handlers/callbacks.ts— Central callback router for all inline keyboard actions.src/services/directory-browser.ts— Filesystem navigation. In-memorypathCacheMap solves Telegram's 64-byte callback data limit.src/handlers/bash.ts— Direct shell command execution via/bash. Runs in current project dir, 30s timeout, output in<pre>block.src/services/telegram-files.ts— Downloads Telegram file attachments (photos, documents) to/tmp/claude-telegram/. Uses Bot APIgetFile+fetch.src/services/whisper.ts— Voice transcription via local whisper.cpp. Lazy capability detection (cacheshasWhisper/hasFfmpegon first voice message). Pipeline: OGG→WAV (ffmpeg -ar 16000 -ac 1) → text (whisper-cli --no-timestamps -l auto). Both ffmpeg and whisper.cpp are optional — if missing, user is offered an install button.resolveModelPath()searches~/.local/share/whisper-models/,~/.cache/whisper/, and Homebrew paths forggml-<model>.bin.resetCapabilities()clears the cached detection (called before auto-install so next attempt re-checks). Temp WAV/OGG cleaned up after transcription.src/state/session-state.ts— SingleBotStateobject.awaitingFolderNamedistinguishes folder name input from Claude prompts. Queue state:queuedMessage,queuedMessageId,queuedChatId.pendingQuestiontracks AskUserQuestion state.
Telegram limits callback data to 64 bytes. Short prefixes:
| Prefix | Meaning | Example |
|---|---|---|
cancel |
Cancel running process | cancel |
p: |
Project list page | p:0 |
ps: |
Project select | ps:3 |
pr: |
Resume latest session | pr:3 |
sl: |
Session list | sl:3:0 (project:page) |
ss: |
Session select | ss:3:5 (project:session) |
sn: |
New session | sn:3 |
new |
New session (from /start) | new |
newdir |
Browse directory (new project) | newdir |
d: |
Directory navigate | d:42 (pathCache ID) |
ds: |
Directory select | ds:42 |
dp: |
Directory page | dp:42:1 (pathId:page) |
cf: |
Create folder | cf:42 |
sh: |
Session history page | sh:0 |
sr: |
Rewind session to message | sr:7 |
cq |
Cancel queued message | cq |
pi |
Implement plan | pi |
pv |
View full plan | pv |
pr |
Refine plan | pr |
slc: |
Session list (current project) | slc:0 |
ssc: |
Session select (current) | ssc:2 |
aq: |
Answer question (single-select) | aq:2 (option index) |
at: |
Toggle multi-select option | at:1 (option index) |
as |
Submit multi-select answers | as |
df: |
Diff file view | df:3 (file index) |
wi |
Install whisper.cpp via Claude | wi |
- 4096 char limit per message — split at ~3800 raw chars, then convert to HTML
- ~5 edits/msg/min — debounce streaming edits to every ~2.5s
- 64 byte callback data — use short prefixes + in-memory path cache for directory browser
- HTML parse mode — only safe subset:
<b>,<i>,<code>,<pre>,<s>. Unclosed tags cause API errors.
escapeHtml()is duplicated across several files (handlers, formatter) — intentional to keep each module self-contained.pathToDirName()converts absolute paths to Claude's project dir format:/Users/foo/bar→-Users-foo-bar. Also duplicated insessions.tsandcallbacks.tssincestate.currentProjectDirmay be null (e.g. when usingDEFAULT_PROJECT_PATH).sessions-index.jsoncan be stale — it may reference sessions whose.jsonlfiles no longer exist on disk.listSessions()validates against actual files and merges in on-disk sessions missing from the index.- UUID-named subdirectories in project dirs are subagent containers, not sessions — only
.jsonlfiles are sessions. - The
cancelledflag on ClaudeProcess prevents post-cancelresult/errorevents from editing messages. Theresulthandler still captures the session ID even when cancelled. - Formatter splits raw text FIRST then converts each chunk to HTML. This was a critical fix — splitting HTML can break mid-tag.
- Tool indicators (
🔧 Bash: ls -la) are embedded inaccumulatedTextduring streaming, then replaced by the final result. When a tool completes, its🔧line is replaced in-place with✅/❌+ line count (e.g.✅ Bash: ls -la (13 lines)). UsesindexOfto match the first unreplaced🔧for that tool name — handles parallel same-name tools correctly. - Cancel/Interrupt: Uses SIGINT (not SIGTERM) so the CLI saves the interrupted turn to the session file before exiting. This preserves full conversation context across interruptions — the next message resumes the same session with
-rand Claude has the complete history including the interrupted turn. Escalates to SIGTERM after 3s, SIGKILL after 6s. The cancel callback keeps the streaming message with accumulated text + "⚠️ Interrupted". If a queued message exists, it auto-drains; otherwise sends "Interrupted · What should Claude do instead?". No/cancelcommand — only the inline ✋ button. - Inline ✋ Cancel button is attached to the "Thinking..." message and all streaming edits via
reply_markup. The keyboard is automatically removed when the final result/error edit omitsreply_markup. - Session JSONL format: Each line is a single content block (not a full message). An assistant turn spans multiple lines: thinking, text, tool_use. User tool_result lines follow. Real user prompts are
{role: "user", content: "string"}lines. - Rewind: Truncates the current
.jsonlin-place at a specific visible message (no fork/copy). Only rewind to assistant messages — rewinding to a user message would leave a dangling user turn, causing two consecutive user messages when resumed. - History:
/rewindcommand. Filters JSONL to show only user text prompts and assistant text responses (skips tool_use, tool_result, thinking, system lines). Messages numbered for rewind reference. - Message Queue: Single-slot queue. When user sends a message while Claude is busy, the user's message is deleted and replaced with a bot "📝 … Queued" notification with ❌ Cancel. Subsequent messages edit the same notification (latest wins). On result/error/cancel, queue auto-drains: notification edited to "💬 …" (preserves context), then
processPrompt()runs the queued text. Queue can also fire after cancel — if user cancels the running process and a queue exists, it sends immediately. - AskUserQuestion: The CLI can't show interactive question UI in
-pmode (returnsis_error=true). We intercept thetool_useevent, capture the question data, and after the result is displayed show the question as a separate Telegram message with inline keyboard buttons. Single-select: tap a button to answer immediately. Multi-select: toggle buttons (☐/☑) + Submit. Free text: user types a message (combined with any checked options for multi-select usingSelected: X, Y\nAdditional input: textformat). Supports 1-4 questions shown sequentially. Answers sent as next prompt in the same session via-r. State tracked instate.pendingQuestion(QuestionData[],currentIndex,answers,selectedOptions). /bashcommand: Runs shell commands directly without Claude. Useschild_process.execwith 30s timeout in the current project directory. Output displayed in<pre>block with HTML escaping.- CLI slash commands work in
-pmode: Sending/compact,/compact <instructions>, etc. as the prompt text in-p -r <sessionId>mode works — the CLI recognizes its own slash commands. Theinitevent'sslash_commandsarray lists all available ones. The result text is typically empty for meta-commands; the bot uses dedicated handlers (e.g.compact.ts) rather thanprocessPromptto show appropriate feedback. /diffcommand: Interactive file picker. Shows file list with+N -Nstats as inline keyboard buttons. Tapping a file sends its diff as<pre><code class="language-diff">message(s) — thelanguage-diffclass triggers syntax highlighting (green/red for +/- lines) on Telegram Desktop/macOS clients. Does NOT useformatForTelegram()— diff markers would be mangled by markdown→HTML conversion. Has its ownsplitPreformatted(). Default mode runsgit diff --numstat HEAD+git ls-files --others --exclude-standard. With args, passes through togit diff. File list and args stored instate.diffFiles/state.diffArgsfor callback lookup (df:N). For untracked files, usesgit diff --no-index /dev/null <file>. Non-blocking — works while Claude is processing. Fallback for repos with no commits (HEADunknown).- Attachments: Photos and documents sent in Telegram are downloaded to
/tmp/claude-telegram/and referenced in the prompt as[Attached image/file: /path]. Claude Code'sReadtool natively reads images and PDFs. Photos use the largest available resolution. Documents keep their original filename. Both support captions — the caption becomes the user's prompt text alongside the file reference. Attachments go through the same queue/routing as text messages (handled byrouteMessageinchat.ts). Media groups (albums): When multiple photos/documents are sent together, Telegram delivers them as separate messages sharing amedia_group_id. These are buffered for 500ms, then flushed as a single prompt with all file paths. Caption can be on any message in the group. Telegram Bot API limits file downloads to 20MB. - Voice messages: Different pipeline than photo/document attachments. Voice goes through
handleVoice→ download OGG →transcribeVoice()(whisper.cpp) →routeMessage()with plain text. Photos/documents go throughhandleAttachment→ download →[Attached: /path]prompt. Voice does NOT use the attachment pipeline. Shows "🎤 Transcribing..." indicator, then edits it to "🎤 " so user can see what was detected. Onlymessage:voiceis handled (notmessage:audiowhich is for music files). Both ffmpeg and whisper.cpp are detected lazily on first voice message and cached for process lifetime. Auto-install: If whisper is missing, shows a "📦 Install whisper.cpp" inline button (wicallback). Tapping it callshandleWhisperInstall()which sends a prompt to Claude asking it tobrew install whisper-cpp, download the base model to~/.local/share/whisper-models/, and install ffmpeg.resetCapabilities()is called before install so the next voice message re-detects. Env vars:WHISPER_MODEL(defaultbase),WHISPER_MODEL_PATH(full path override). - Plan Mode: Claude can enter plan mode via
EnterPlanMode/ExitPlanModetools (works in-pmode). Plans are stored as MD files in~/.claude/plans/with random names (e.g.flickering-leaping-eich.md). TheExitPlanModetool_use event containsinput.planFilePath— the bot captures this to populatestate.currentPlanPath. After a plan-creating result, bot sends a notification with ✅ Implement / 📋 View Plan / ✏️ Refine buttons. Plan files are never deleted (matches CLI behavior — they're permanent records).ExitPlanModereturnsis_error: truein-pmode (can't show confirmation UI) — Claude continues anyway and writes a text summary. Refine prompts the user for feedback, which is sent as the next message in the same session. Trigger gotcha: prompts like "create a plan" often produce a text-only plan without entering plan mode. Include "plan mode" or "use plan mode" explicitly in the prompt to triggerEnterPlanMode/ExitPlanModetools (e.g. "Use plan mode to plan adding feature X"). - Context usage footer: Shows
📊 X% context | ⏱ Xsinstead of cost. Calculated frommodelUsagein the CLI result event:(inputTokens + outputTokens + cacheReadInputTokens + cacheCreationInputTokens) / contextWindow * 100. The ~3% baseline on a 1M model is real — it's the CLI's system prompt, tool definitions, and CLAUDE.md. - messageCount: Counts only visible messages (same filter as
getSessionHistory— skips tool_use, tool_result, thinking, system/XML lines). This ensures/resumemessage count matches/rewindmessage count.
| Variable | Required | Default |
|---|---|---|
TELEGRAM_BOT_TOKEN |
Yes | — |
TELEGRAM_USER_ID |
Yes | — |
CLAUDE_CLI_PATH |
No | claude |
DEFAULT_PROJECT_PATH |
No | null |
STREAM_UPDATE_INTERVAL_MS |
No | 2500 |
PROCESS_TIMEOUT_MS |
No | 300000 |
WHISPER_MODEL |
No | base |
WHISPER_MODEL_PATH |
No | null |