A Telegram dispatcher for Claude Code sessions, running on Linux or WSL.
From your phone it lets you see the list of sessions, start new ones in any
directory, bring closed ones back, send prompts and /-commands, and — above
all — answer Claude's interactive questions with buttons.
You need Linux or WSL, Claude Code
installed and signed in, and tmux. The installer handles everything else.
git clone <url> claude-tg-bot && cd claude-tg-bot
./install.shIt checks the prerequisites, builds the virtualenv, and asks for the bot token from @BotFather — validating it on the spot, so a mistyped token is caught in two seconds rather than five minutes later in the log. Then, instead of sending you off to look up your numeric Telegram id, it asks you to message the bot and reads the id off that message: one action that also proves you can reach the bot. Finally it wires the status-line hook, installs the systemd user unit, starts the bot, and confirms the whole chain works by sending you a message in Telegram.
Run it again whenever you like — it is also the update path
(git pull && ./install.sh), and it never overwrites a working .env without
asking. ./install.sh --doctor inspects an existing install and changes
nothing.
Windows is not supported natively and cannot be: the bot drives the Claude
Code TUI through tmux. Install WSL (wsl --install in PowerShell) and run
everything inside it. macOS runs the bot fine, but has no autostart
template yet — install by hand, as below.
The built-in channels (--remote-control, --channels) are blocked by a
corporate org policy here. So the bot drives sessions from the outside:
| Direction | Mechanism |
|---|---|
In (prompts, option choices, /-commands) |
tmux paste-buffer / send-keys |
| Out (the text of the answers) | ~/.claude/projects/*/<uuid>.jsonl |
| Blocking dialogs | tmux capture-pane + ccbot/screen.py |
| The list of live sessions | claude agents --json |
Answer text comes from the transcript rather than the screen: it is clean, free of ANSI, unwrapped and not limited by the scrollback. The screen is needed only for dialogs.
- Managed — created by the bot inside tmux. Full control.
- Foreign — started by you in an ordinary terminal. View only: their stdin is out of reach from outside, and hijacking them would mean ruining work that is already under way.
- Closed — historical transcripts; brought back with
--resume.
install.sh is only the automation of the steps below, never the only place
they are written down. If you would rather not run a script, this is the same
install by hand.
1. Prerequisites. tmux, plus Claude Code installed and signed in —
claude agents --json has to answer, since being on PATH is not the same as
being usable. jq matters only for step 4.
2. Environment. Python 3.12 or newer:
uv venv && uv pip install -r requirements.txt
# no uv? python3 -m venv .venv && .venv/bin/pip install -r requirements.txt3. Credentials.
cp .env.example .env && chmod 600 .envTG_BOT_TOKEN comes from @BotFather (/newbot).
TG_ALLOWED_USER_IDS is your numeric Telegram id — @userinfobot
tells you yours. It has no default on purpose: an empty list would mean a bot
that runs shell sessions for anyone who finds it, so the bot refuses to start
without one.
4. Exact metrics (optional). Point statusLine.command in
~/.claude/settings.json at bin/statusline-tee.sh; if you already have a
status line, keep it by naming it in CCBOT_REAL_STATUSLINE. Skipping this
costs precision, not function — the bot falls back to reading the screen. See
Exact metrics.
5. Run it. .venv/bin/python main.py, or install the systemd user unit —
see Autostart.
6. Check. ./install.sh --doctor verifies all of the above and tails the
log, whichever way you installed.
The session card shows the current values (read off the TUI status line) and
offers buttons to change them. /menu opens that card for the session you are
talking to — the buttons below used to be reachable only by walking through
/sessions and tapping a row:
-
🧠 Model and ◉ Effort open Claude Code's own dialogs —
/modeland/effortare sent to the session, and the card is the dialog itself, with one button per row it offers. Nothing is listed in the bot: a table in the source went stale the day Fable 5.1 shipped, andultracodeis not always on the slider at all. A/modelyou type yourself comes out as the same card.Under the list sits the choice the terminal's footer offers: this session (
s) or + new sessions (Enter, which Claude Code saves as the default for new sessions). It defaults to this session alone — pressing a model used to move the default for every session started afterwards, with only a warning to say so. -
🔐 Mode — there is no slash command for it, so the bot presses Shift+Tab until the status line shows the mode you asked for. The cycle is
auto → manual → accept edits → plan.bypassPermissionsanddontAskcan only be set when the process starts.
The message text does not restate what the buttons already say — by the time you have scrolled a long list, the summary is forgotten. So the top carries counters only, and all the information lives on the buttons themselves:
🕘 Closed sessions — resuming brings the context back.
📁 /home/vik/7loc/backend/pay4say
88 sessions · tap one for details.
[09:33 · Widget rework of the mileage report]
[08:32 · Paddle billing agreement, second try]
[Sun 21:25 · The last deploy]
[◀️] [page 2 of 11] [▶️]
[📁 Another directory] [⬅️ Sessions]
History is per directory, and paged. It has to be: a project accumulates
dozens of transcripts (88 in that example), and a single global list of the
twelve newest — which is what this used to be — buried every one of them under
whatever other project was touched last. 🕘 Recent opens the active session's
own directory straight away, and 📁 Another directory lists the rest with a
count each:
🕘 Closed sessions — 185 in all, by directory.
[📁 ~/dev/finman · 60 · 06:30]
[📁 ~/7loc/backend/pay4say · 88 · yst 20:39]
[📁 …/7loc-admin-web · 10 · yst 20:34]
The button format is time · what the session is about; in the by-directory
list it is path · how many · last activity. Inside one directory the path is
left off the rows — it is the same on all of them, and the caption is better
spent on what tells them apart.
The name itself comes from the first of these that exists:
- what you called it —
/renamekeeps the name in a table of its own, so it survives the session ending and comes back with--resume. Such a row is marked✏️. claude -n, when a person wrote it — the bot's ownpay4say-7fdais recognised and skipped.- the AI title Claude Code invents.
- the first prompt — with the wrapping the bot itself added stripped off,
so a photo sent from the phone reads as its caption rather than as
Take a look at these 3 images: /home/…/media/….
Fourth is the common case: Claude Code has all but stopped writing ai-title
(one of the sixteen sessions on 31.08.2026 had one), which is exactly why the
other three exist.
🔍 Find searches every directory at once — names, first prompt, last prompt.
Not the body of the conversation: that would mean reading two hundred files of
a megabyte each per query, while the four fields are already in the scan cache
and answer in milliseconds.
A card adds ⏹ It stopped on: next to 💬 It started with:. Of the two, the
last prompt is usually the one that says which session this was.
The page a card was opened from travels in its callback_data
(res:<sid8>:<dir key>:<page>), so "⬅️ Back" returns to page 7 rather than to
the top. The directory key is a hash of the path, not its position in the list:
positions move as sessions open and close, and an older card would then page
through a different project.
The bot calls a managed session directory-1a2b — unique, but no help in
telling two sessions of the same project apart. So the name in the list and in
message captions is taken from three sources in turn:
- your own name —
/rename Billing audit, or ✏️ on the session card, or just/renamepicked out of the "/" menu: it asks which name, and takes your next message as the answer (with a ⬅️ Cancel button, and it lapses after three minutes rather than swallowing a message meant for Claude); - the title Claude came up with (
ai-titlein the transcript) — managed sessions rarely get one: the bot startsclaude -n <name>, and Claude does not invent a title for a session that already has one; - the session's first prompt — the watcher reads it out of the transcript itself.
In messages the name comes with the directory in front — pay4say · pricing —
because several projects write into one chat at the same time and the name
alone does not say where the work is happening. If the name already starts with
the directory, the prefix is not repeated. In list buttons the directory is a
column of its own, so the name there goes without it.
Renaming changes only what you see: alongside it the database keeps name,
the name claude -n was started with. That one never changes, and it is how the
session is found again after /clear (see below).
The first prompt sits behind a file-history preamble that easily grows past 250 KB, so it is looked for in the first megabyte of the file rather than the first 128 KB — otherwise a session would end up with no name at all (and a closed one would also drop out of the 🕘 list).
The most treacherous breakage that had to be caught here: after /clear,
Claude does not restart — same process, same window, same -n name — but
it starts a new transcript under a new session id. Nobody announces this:
the old file simply stops growing. A bot reading the old id then goes silent
forever — prompts still arrive (they go through tmux, not through the id),
Claude still answers them, and the answers are written where nobody is reading.
The thread back is the launch name: claude agents --json shows it next to the
new id. So on every tick the watcher reconciles managed sessions against live
agents, and when a managed session's id is missing from the live ones while an
agent with the same name and directory is present, it rebinds the session to
the new id: same window, same name, same reply routes, offset back to zero. A
line arrives in the chat: "🧹 name: context cleared — the session was given a
new id, still watching it."
That is exactly why renaming touches custom_name only: had it changed name,
the thread would snap and the session would be lost after the very first
/clear.
The rebind moves the row, the active-session pointer and the reply routes to
the new id — but not the buttons already sitting in the chat: a card carries
the id it was printed with, cut to eight characters, inside its
callback_data. So every control on a card older than the last /clear used
to answer "that session is gone", which is what happens to the card you
cleared from: press 🧹, then ✏️ Rename on the same card, and the rename was
refused. Hence session_aliases, a table of "the id this one became", written
by Store.rebind and read by Store.find_prefix (and by Store.follow, for
the two places that write an id down outside the row — a half-finished rename
and a dialog waiting for its text). Aliases already pointing at the old id are
moved along with it, so a session cleared three times is still reachable from
its very first card; they expire after 30 days. The "context cleared" note now
also carries a 🎛 button to the fresh card.
Claude writes the transcript in bursts, so a long turn with reasoning leaves
nothing in it for minutes — and in a chat that is indistinguishable from a
hung bot. The terminal, meanwhile, counts out loud:
✢ Fluttering… (5m 14s · ↓ 13.7k tokens).
So after 75 seconds of uninterrupted work the bot sends one line,
⏳ <name>, carrying that counter, and then edits it in place every 45
seconds. When the real answer arrives, the pulse is deleted so it leaves no
litter. The "esc to interrupt" hint is cut out of the line: there is no way to
press it from a phone.
It is specifically the line with a spinner and a timer in brackets that is picked up — "✻ Worked for 8m 9s" without brackets means the turn is already over, and no pulse is due.
A user unit does not read your shell profile, so nvm is not on its PATH —
and claude simply does not exist as far as the bot is concerned. The
consequence was insidious: lists of live sessions silently came back empty,
with no error anywhere.
So the bot resolves the CLI itself: CCBOT_CLAUDE_BIN → PATH →
~/.nvm/versions/node/*/bin/claude → ~/.local/bin → /usr/local/bin. The
path it found is written to the log at startup, and if nothing is found the bot
sends a warning to the chat rather than staying quiet. The unit also spells out
PATH explicitly.
Written to ~/.cache/ccbot/bot.log (rotating, 2 MB × 3) and to journald in
parallel. /log shows the last lines right in the chat.
It is recorded systematically rather than in selected spots: an outer middleware catches every update before any handler — including the ones that are dropped afterwards (a sender who is not allowed, no handler matched). Those are precisely the cases that are hardest to reconstruct after the fact. On top of that: session lifecycle, prompts sent, watcher decisions (a dialog, a flush, a threshold, a session gone) and rejected users.
CCBOT_LOG_LEVEL=DEBUG adds every tmux command as well.
The worst state to be in is a bot that is alive while every action quietly fails: in a chat that is indistinguishable from "Claude is just thinking". So an exception never simply stays in the log:
- an exception in a handler is caught by
@dp.errors, which answers in the same chat — what broke (type and message) and that sessions are unaffected; - an exception in a watcher tick stops delivery of answers from every session, so it gets a warning of its own. The tick repeats every second and a half, so the same breakage reports once an hour, and when the loop recovers, "✅ the watcher is working again" arrives.
This is what the failure that prompted it looked like: a running process had an
old version of the code while the database had already gained a new column,
every tick died with a TypeError, the unit stayed active, and the bot said
nothing until somebody restarted it.
Sessions live independently: starting a task in finman and then opening
7loc does not stop the first one — it keeps working, and its answers arrive
captioned with its own name. The watcher follows every managed session at once.
The active one is whichever you last created, resumed, or opened with the
"▶️.
Buttons are always addressed — the session id is baked into them, so an answer to Claude's question lands exactly where the question came from, whichever session happens to be active.
For everything else there is reply: replying to any message from a session
goes to that session — both text and commands (/clear, /esc, /usage,
/screen, /exit, /rename). It matters when two tasks are running side by
side: otherwise it is easy to wipe the context of the wrong session while
reading a message from another. A reply also makes that session active:
without that, the next message sent without a reply would go back to the
previous one, and "where I just answered" would drift apart from "where the bot
is writing" all over again. The confirmation shows it —
➡️ Sent to <name> · in reply, now active.
The bot's own confirmations are routed too: a reply to ➡️ Sent to … lands in
that same session, so you can answer the last message in the chat without
hunting for the previous one.
Registered through setMyCommands, so they show up in the "/" menu — nothing
has to be typed by hand.
| Command | What it does |
|---|---|
/sessions |
the session list, cards, controls |
/menu |
one session's card, without the list in front of it: model, effort, permission mode, context, rename, end |
/new |
start a session in a chosen directory |
/usage |
quota and context, without asking Claude |
/screen |
a text snapshot of the terminal |
/esc |
presses Esc — interrupts what Claude is doing. The session stays alive, its context untouched |
/clear |
clears the session's context. The session lives on, its history is gone |
/dirs |
the project directories offered when starting a session |
/rename |
your own name for a session (/rename - gives the automatic one back) |
/lang |
interface language (en and uk for now) |
/update |
which Claude Code each session runs, which one is on disk, and a restart that keeps the context |
/exit |
ends the session: Claude shuts down on its own, then the tmux window closes |
All of them act on the active session, or — in a reply — on the one whose message you are quoting.
A distinction worth keeping in mind: /esc stops the action, /clear erases
the memory, /exit closes the session. After /exit the transcript stays,
so the session can be brought back from 🕘 in the list.
/usage and the card's "📉 Context breakdown" are not the same thing either.
/usage is the bot's own summary, read off the status-line payload: how much of
the 5-hour and 7-day quota is spent, how full the context is, plus model, effort
and cost. It is instant, costs no tokens and does not disturb the session.
"📉 Context breakdown" runs Claude Code's own /context in the session and
sends back the breakdown it draws — what is filling the window: system prompt,
tools, MCP, memory files, messages. "🗜 /compact" next to it runs /compact:
the conversation is summed up and the session carries on with the summary
instead of the history, keeping its id, its name and its tmux window.
Both of those print their answer on the terminal and nowhere else — a local
command leaves no assistant record, and only assistant records are forwarded —
so the bot promises a screen instead: the snapshot arrives once the session has
gone quiet (Watcher.screen_when_idle), which for a compaction is when it has
finished rather than when the key was pressed.
Any other /command (/model, /compact, /cost, /context…) is passed to
Claude unchanged.
The process is not killed. /exit goes into the session — Claude shuts itself
down and gets to finish writing the transcript; only then is the tmux window
taken away. Calling it kill would promise the opposite. Ctrl+D does not work
here: the TUI ignores it (verified).
Claude Code updates itself in the background, but a running process keeps the
build it started with: the new one reaches a session only when its claude is
started again. Nothing in the TUI says so, so a session can sit three releases
behind for a week and look perfectly healthy.
/update puts both numbers on one card — the version on disk, the version each
session is actually running — and offers the restart that closes the gap.
⬆️ Claude Code
On disk: 2.1.251 — what a session gets the moment it is started again.
✅ finman · calendar — 2.1.247 ⬆️
✅ pay4say · widget — 2.1.245 ⬆️
▶️⏸ claude-tg-bot · the update card — 2.1.241 ⬆️
3 sessions are behind.
Busy or waiting, so left alone: claude-tg-bot · the update card
Where the numbers come from. On disk: claude --version, asked at most
every five minutes — it only moves when the background updater has been at
work. In a session: the version field of the status-line payload, which the
process rewrites on every status-line render, with the transcript as the
fallback where the tee is not installed. Both are written by the process
itself, so they describe the build that is running and not the file on disk.
What a restart is. /exit into the session, then
claude --resume <id> -n <name> in the same tmux window. The session id
survives a resume, so the transcript keeps growing in the same file, the
reader's offset stays valid, and nothing is replayed into the chat. The launch
name comes back exactly as it was — it is the only thread back to a session
that is later /cleared. The context comes back with the history; the prompt
cache does not, so the first reply after a restart takes a little longer, and
anything half-typed into the session's input line is gone.
The self-update is the hazard here. Claude Code replaces its own binary in
place, and for a second or two claude is missing — or half-written, which
bash reports as Exec format error. A session shut down inside that window has
nothing to come back to: that is exactly how one session ended up at a shell
prompt on 2026-08-30. So the CLI is checked before anything is shut down, and
a launch that does not come up is tried once more. Even then the window is
left standing, and pressing restart again resumes the session.
What it refuses to do. A session that is working, or waiting on a question,
is left alone — checked twice over, because neither source is enough on its
own: claude agents --json (asked fresh, not from the five-second cache) knows
about dialogs the screen parser does not recognise, and the screen answers
immediately for a session that has only just been relaunched and is missing
from the agent list for a couple of seconds. If /exit does not finish in
20 seconds, or the resumed claude does not appear within 40, the restart
stops and says so — the window is never killed, unlike /exit from the menu.
"🆕 What's new" answers the obvious next question — what a restart would
actually bring in. Claude Code keeps the full changelog at
~/.claude/cache/changelog.md and refreshes it when it updates itself, so the
notes are read from disk: no network, no tokens. The card shows exactly the
releases between the oldest session still behind and the version on disk
(2.1.247 → 2.1.251 — 3 releases), newest first, and names how many older ones
were left out. /release-notes inside a session is the same thing from the
other side.
"🔄 Check for updates" runs claude update, which is the CLI's own
"check and install if there is anything". It changes what is on disk and
nothing else: the restart is still yours to press.
Being told about it. When a release lands on disk while sessions are behind,
the watcher says so once — one message per version, remembered in
config.json, so a bot restart does not turn one release into a second
notification. Between messages the badge is enough: ⬆️ and a count in
/sessions, the two versions on the session card, and the disk version in
/service.
- managed (
⏸ ⚡ ✅) — created by the bot in tmux, full control; - foreign (
🔗) — started by you in an ordinary terminal. They cannot be driven from here, but they are visible, and the "🔗 Move to tmux" button sends the process a SIGTERM and bringsclaude --resumeup in a tmux window. The card says so plainly, and separately warns when the session is busy right now; - closed (
🕘) — history, brought back with--resume. Sessions where nothing happened (only/exit) do not make the list: there is nothing to resume. Judging that has to be done by content, because even an empty session weighs hundreds of kilobytes thanks to the file-history snapshot.
There is no reaching the stdin of a foreign session from outside, so a clean
/exit is impossible for one — hence a signal instead of a command.
A button caption is a single line, so the full name and path do not fit there.
Picking a session from the list therefore opens a card with every known
attribute first, and only the "
▶️ claude-tg-bot-b7e1
📁 /home/user/dev/claude-tg-bot
📊 Status: idle
🧠 Opus 5 (1M context) · ◉ xhigh · 🔐 auto
📈 Context: 5% (46,864 tokens)
5 hours: 27% · resets today 15:10
7 days: 24% · resets Fri 22:00
💵 Session cost: $0.21
🕘 Created: 23.08 12:09
🖥 tmux: @3
🔑 6789ea9b-2bcc-4626-8e9e-97ee33c717dc
[▶️ Open] [⬅️ Back]
A closed session gets a card of its own — full name, path, time of last activity, the first prompt (when the name came from an AI title), transcript size — and only then "🕘 Resume".
A fresh session has no metrics yet: used_percentage and rate_limits come
back null in the payload until the first request has gone through. The card
says exactly that instead of showing zeros.
The figures come from the same JSON Claude Code hands its status line, not from
the bars that line draws. The bars are rounded to 10 %, reset times to the
minute, and the labels depend on how the script is written; the payload, by
contrast, carries exact context_window.used_percentage, the window size,
rate_limits.*.resets_at (epoch), effort.level, the model and the session
cost.
The interception is a wrapper that does not change your status line:
"statusLine": { "command": ".../claude-tg-bot/bin/statusline-tee.sh" }
It mirrors stdin into ~/.cache/ccbot/status/<session_id>.json and hands it on
to your own script — ~/.claude/statusline-command.sh, or whatever is named in
CCBOT_REAL_STATUSLINE. If you have no status line of your own, the wrapper
draws a minimal one itself rather than leaving a blank. Every failure path
inside is non-fatal: if the write fails, the status line still renders as
before.
./install.sh wires it up for you, backing up settings.json first. If a
status line is already configured, the installer does not lose it: it generates
a one-line wrapper, ~/.config/ccbot/statusline.sh, which sets
CCBOT_REAL_STATUSLINE and calls the tee. The variable is not written straight
into statusLine.command because nothing promises that field is run through a
shell — and VAR=x /path without a shell means nothing.
Remove the wrapper from settings.json and the bot falls back to parsing the
screen on its own (warning about it in the log) — the numbers simply get
coarser.
Three ways, because each one alone has a flaw: showing it constantly is noise, showing it only on request means remembering to ask, and thresholds alone never answer "how much right now?".
- On request —
/usage, or the "📊 Limits" button: context as a percentage and as tokens out of the window size, every limit with its reset time, plus the model, the effort level and the session cost. - Automatically at thresholds — context at 60/75/85/95 %, limits at
80/90/95 %. Each step fires once and rearms when the value drops (after
/clear, or with a new limit window). - A quiet suffix —
· ctx 62% · 5h 81%appended to Claude's answer, but only once context is ≥ 50 % or a limit is ≥ 75 %. While there is plenty of room, there is no noise at all.
Figures older than 3 minutes are marked stale — the session simply has not redrawn its status line in a while.
Reset times ("resets tomorrow 09:00") are quoted on the host's own clock. WSL
images often sit on Etc/UTC even though you do not, and a bot on a rented
server is in the same position, so set CCBOT_TZ=Europe/Kyiv in .env when the
host clock is not yours.
Multi-step questions arrive one at a time: answer, and the next one comes.
When an option has an ASCII diagram, Claude draws it to the side and only for the highlighted row. The bot splits the screen into columns: option labels go onto buttons, and the diagram is delivered according to its width.
For years this was the hard part: mobile Telegram did not scroll a code block, it wrapped it, so a wide diagram fell apart on a phone while looking flawless on the desktop. Anything wider than 36 characters had to leave as a PNG.
Bot API 10.1 (June 2026) removed the problem. A rich message's preformatted block scrolls sideways, so the diagram now travels inline as text at any width — it lines up, and it can be copied. Two things were measured on a live client rather than read in the documentation:
- a single block accepted 30 000 characters, where the docs promise 1024;
- box-drawing (
─ │ ┼) is not a letter wide in Telegram's monospace face, so a captured frame comes out ragged. Plain ASCII (- | +) lines up exactly, which is whatrich.ascii_frame()converts it to.
The PNG path (ccbot/render.py, Pillow + DejaVu Sans Mono) stays underneath as
the fallback for a client that has no rich messages; "🖼 Diagram" still redraws
the current option on demand.
A small thing that cost time back then, and still applies to the fallback: the
lines in the PNG overlap by a pixel (LINE_SPACING = -1) — otherwise the │
glyph renders a hair shorter than its cell and every vertical comes out dotted.
Parsing the columns is needed precisely so the graphics do not end up inside option labels. The question and the heading are rendered across the full width, so they are not cut at a column boundary, and the frame characters are stripped off their edges.
The ⬆️ ⬇️ ✅ buttons page through the list, and the message is edited rather than duplicated. The arrows also reach options with no number ("Chat about this") that no digit corresponds to.
A question offers two free-text rows, and they behave nothing alike.
"Type something" is an input line inside the dialog: the digit moves the cursor onto the row, the text is typed into it, and Enter answers the question. One turn, and the answer is recorded as the answer. That is the route the ✏️ button takes — it asks for the text first, then types it in.
"Chat about this" answers nothing. The digit alone rejects the question, with an instruction that has Claude ask what you would like to clarify — so whatever is sent along with the press is read only after that question has been asked and answered. The 💬 button therefore presses and stops: the question is dropped, the session becomes the active one, and the next thing written in the chat goes there as an ordinary message. One message, one turn.
Answering from a card also makes that session active. It has to: what follows an answer belongs to the same thread, and plain text goes to the active session. Before that, a clarification typed after pressing a dialog button could land in another session entirely — which happened on 2026-09-01, and the other session started working on a question nobody had asked it.
A multi-select question (AskUserQuestion with multiSelect) is not a list of
answers but a list of ticks, and its digits behave accordingly: pressing "1"
ticks the first box and leaves the question standing. What sends the ticked set
is an unnumbered row under the list — captioned Submit on the last section of
a question, Next while sections remain — and no digit reaches it.
So the chat gets a button of its own for that row ("✅ Send 2 choices" / "➡️ Next question"), the buttons carry ☑ / ☐, and pressing one edits the card instead of retiring it. Under the button the cursor is walked onto the row and the screen re-read after every move: Enter on the wrong row would answer with that single option instead of the set. Whatever follows — the "Ready to submit your answers?" review — is confirmed by the bot itself, since the chat has just confirmed the same thing.
Without that button the question dead-ended: the boxes could be ticked from the phone and there was no way to send them.
Anything Telegram can carry — a photo, an album, a document of any type, a
video, an audio file, a voice message, a sticker — is saved into
~/.cache/ccbot/media/<session>/ and handed to Claude as a path. Its Read
displays images natively; a JSON, a log or a CSV it simply reads, and an
archive or an audio file it can unpack or convert on its own. The system
clipboard is not used here: WSL has no display, so there is no way to paste a
picture "with Ctrl+V".
The format is not filtered. It used to be — images and PDFs only, and a forwarded JSON came back as "I only take images and PDFs", which is a bot deciding what Claude is capable of reading. The only real limit is Telegram's: a bot may download 20 MB, and past that the answer says so and suggests sending a path instead.
A file keeps the name it was sent with (sanitised, and given a -1 suffix
rather than overwriting a namesake): the path is all Claude sees, and
dialog-with-maksym.json says more than 20260828-131032-0.json. Only what
arrives nameless — a photo, a voice note — is named by timestamp.
- with a caption — it goes straight through together with it;
- without one — it waits for the next text message;
- an album is gathered into a single prompt.
The prompt says "image" only when every attachment really is one; otherwise it
says "file", and inside a forwarded transcript the marker is [file 2] rather
than [image 2].
Attachments older than 14 days are cleared out when the bot starts.
Nothing goes to a session the moment it arrives. Every incoming message lands
in a per-chat inbox and leaves 0.8 s after the last of them
(_INBOX_QUIET) — as one prompt, ordered by message_id. An attachment
still coming down from Telegram holds the batch open, so the picture is never
mentioned in a prompt that arrives before the file exists.
The reason is what a phone actually sends. Forwarding a conversation produces a
message per bubble, Telegram hands the whole lot over in a single poll (13 of
them within 190 ms on 2026-08-27), and aiogram runs an update per task. Sent
straight through, that became 13 prompts in whatever order the event loop
scheduled them, each with its own Enter — and 13 simultaneous
tmux load-buffer/paste-buffer pairs on one buffer name, of which 11 died
with no buffer ccbot-7, because paste-buffer -d deletes the buffer another
call was about to paste. Hence both halves of the fix: the inbox, and one lock
per window in tmux.py (tmux.submit_text — paste and Enter, indivisible).
A forwarded batch also keeps its authorship. forward_origin says who wrote
each message — a visible user, a hidden one, a group, a channel — and the
prompt becomes a transcript:
Take a look at this image:
1. /home/vik/.cache/ccbot/media/0444d8e4/20260827-131032-0.jpg
Forwarded from Telegram, 13 messages, oldest first:
Andrii: hi!
Andrii: did you not set the plans up??
Vik: Hi
Andrii [image 1]: why like this??
…
Without the names, two people answering each other collapse into one voice contradicting itself. Messages the user typed themselves have no author to name, so they are simply joined by a blank line, and a single message is passed through byte for byte — the ordinary case is untouched.
Two things deliberately skip the queue: a /command bound for Claude (it
flushes whatever is waiting and then goes alone, because a command has to be
the first thing on its line) and an answer to a question the bot asked
("send me a path", a dialog option), which is a conversation of its own.
Forwarding a conversation is normally how a session is given the context it
should start from, so its destination is a question rather than "whichever
session is active": the batch is held (_Parked) and the chat is asked, with
«
Three details make it usable rather than merely correct:
- Everything sent while the question is open joins the same batch, and the question is deleted and asked again at the bottom. The comment that explains a forward ("look at this") is part of the forward, and a question scrolled above the new messages is one that gets answered for the wrong batch.
- Both "new" and "recent" go through
_create, which delivers the batch itself once the session is up (_deliver_after_launch), so there is one delivery path instead of three. It waits for the TUI first (screen.is_ready, the mode line under the input box):paste-bufferdoes not care whether Claude is running, and text pasted into the shell prompt a fresh window shows for a second would be run. A directory Claude Code has not seen before answers with "Is this a project you trust?" instead — that is not readiness either, so the screen is sent, the batch stays parked and the new session is now the active one, which makes «▶️ Current» the single tap that finishes the job. - A forward sent as a reply to a session's message is not asked about —
the reply has already said where it goes — and the answer makes that session
active, because what the user types next belongs to the thread they have just
started. A batch nobody routes expires after an hour and says so; a file that
arrives before any session owns it is downloaded under
media/inbox.
Anything the user types themselves still goes straight to the active session: a question per message would make the ordinary case cost a tap.
The list of directories for "➕ New session", and the filter on the resume list, are nowhere hard-coded. There are three sources, in descending priority:
CCBOT_DIRSin.env— a fixed list for this machine. When it is set, the/dirsbuttons will not change it, and the bot says so./dirsin the bot — add or remove a directory straight from the phone; stored in~/.config/ccbot/config.json.- Auto-discovery — with neither of the above, directories are taken from the history of your own Claude sessions. A fresh clone shows something meaningful right away.
The roots are always present, and under them the subdirectories worked in recently. A directory outside the list stays reachable through "✏️ Other path".
The home directory is a fourth root, an implicit one, and only as an exact
match: ~ cannot be put on the list — as a root it would take in every project
below it and leave no shortlist at all — while sessions do get started there.
Without it their transcripts were the one thing the history could not show,
however many of them there were.
The interface is not nailed to any one language: the strings live in gettext catalogues, and the base language of the code is English. Ukrainian is a translation like any other that may follow.
The language is decided from three sources, in descending priority:
/langin the bot — an explicit choice, stored in~/.config/ccbot/config.json;- the Telegram profile's
language_code— a guess that is almost always right, which is why the bot asks nothing on first start; - English — when there is no translation for the profile's language.
English, and not empty strings: the
msgidin the code is the English text, so the worst a broken.mocan do is leave the interface in English.
/lang takes effect without a restart. Handlers get the language from a
middleware on every update, and the watcher — a background loop with no update
to look at — rereads the setting on every tick and opens the context itself.
The "/" menu is republished along with the language, since Telegram would
otherwise keep showing the old command list.
Why gettext rather than JSON dictionaries: .po is understood natively by
Crowdin, Weblate and POEditor — a translator can add a language without
touching Python. And ngettext gives the correct three Ukrainian plural
forms ("1 файл / 2 файли / 5 файлів"), which are almost impossible to get right
by hand.
locales/
bot.pot # template, generated from the code
en/LC_MESSAGES/bot.po|.mo # the base: msgstr repeats msgid
uk/LC_MESSAGES/bot.po|.mo # a translation
The .mo files are committed: git clone && python main.py has to work
without Babel. Babel is needed only by whoever changes the texts.
There are only two languages to begin with, and that is deliberate: nobody will ever see or correct a machine translation into a language they do not read. A new language arrives through a PR.
uv pip install -r requirements-dev.txt
# 1. create the catalogue (German, for example)
.venv/bin/pybabel init -i locales/bot.pot -d locales -D bot -l de
# 2. translate the msgstr entries in locales/de/LC_MESSAGES/bot.po
# 3. build the .mo — that is what the bot actually reads
.venv/bin/pybabel compile -d locales -D botOne more step that is easy to miss: add a line to LANGUAGE_NAMES
(ccbot/i18n.py) — the name of the language in that language. A "Deutsch"
button helps somebody who does not read English; "German" does not. Without
that line the button shows DE.
After changing strings in the code, the template and every translation are updated like this:
.venv/bin/pybabel extract -F babel.cfg -k __ -k N_ \
--add-comments=TRANSLATORS: -o locales/bot.pot .
.venv/bin/pybabel update -i locales/bot.pot -d locales -D bot
.venv/bin/pybabel compile -d locales -D botupdate marks changed strings fuzzy — an invitation to reread the
translation, not litter: bin/check.sh will not let a commit through while any
such marks remain. It also catches a .po newer than its .mo, the most common
mistake of all ("translated it, forgot to compile").
A few rules that keep translations usable:
- emoji belong inside the string (
"📋 Sessions") rather than being glued on in code: a sign that reads unambiguously in one culture may not in another; - named placeholders (
{name}, not{}), or the translator cannot tell what goes where; - HTML travels with the string — otherwise word order cannot be changed;
- logs are not translated: they are for the developer, and their language is English.
Under XDG, not next to the code: the same checkout may be shared, read-only or packaged, and somebody's data has no business living inside it.
| What | Where |
|---|---|
| Settings the bot changes itself | ~/.config/ccbot/config.json |
| State (managed sessions, reply routes) | ~/.local/share/ccbot/state.db |
| Log, attachments, status-line payloads | ~/.cache/ccbot/ |
The exception is locales/: translations are part of the code, not user data,
so they live in the repository.
XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_CACHE_HOME are honoured. A database
in the old location (next to the code) is moved by the bot itself on first
start.
bin/check.sh — four layers, cheapest first: ruff (dead imports, undefined
names, ordering), basedpyright (types), an import check (the package
loads at all) and locales (.mo not behind .po, no fuzzy entries left).
The same script is installed as a pre-commit hook, so a broken tree simply does
not get committed:
uv pip install -r requirements-dev.txt
git config core.hooksPath bin/githooks # the hook is local; redo it after a clone
bin/check.shThe type checker is not a formality here: it catches exactly what otherwise
surfaces at runtime and takes a session down — a renamed Store method left
behind at a call site, or a reference to c.message, which Telegram serves as
InaccessibleMessage for a card older than 48 hours. The emergency way around
is git commit -n.
The checks run against the working tree, not the index: the bot is started from the tree, and a tree that fails is a bot that will not come back up.
./install.sh does exactly this and verifies the result. By hand: the unit
template lives in contrib/claude-tg-bot.service, with %INSTALL_DIR%
substituted at install time:
mkdir -p ~/.config/systemd/user
sed "s|%INSTALL_DIR%|$PWD|g" contrib/claude-tg-bot.service \
> ~/.config/systemd/user/claude-tg-bot.service
systemctl --user daemon-reload
systemctl --user enable --now claude-tg-botThe unit lives as long as you have at least one session on the machine. To keep
it running with none: sudo loginctl enable-linger $USER.
The tmux server is started by the bot itself, so it ends up in the unit's
cgroup. With the usual KillMode=control-group, every systemctl restart would
take down the whole cgroup — that is, the tmux server and with it every Claude
session. Verified on a twin unit: with the default mode the server's PID after
a restart is a different one; with KillMode=process it is the same.
Hence KillMode=process in the unit: only the bot gets the signal, and sessions
carry on. tmux panes, incidentally, already live in their own
tmux-spawn-*.scope, but without this option the server dies along with the bot.
KillMode=process saves the sessions from a bot restart; nothing saves them
from a restart of the machine. The tmux server dies with everything in it, and
the bot — which comes back with the unit — used to answer that with a tombstone
per session ("💀 name is gone (its tmux window was closed)") and delete the
row that named it. Nine of them arrived at once when Windows rebooted itself on
2026-09-09, and with the rows went the only record of what had been running
where.
Nothing about it is unrecoverable, though: the transcripts are on disk, and
claude --resume <id> brings a session back with its whole context. What was
missing was the id, the directory and the names — all of which are in the row
that was being thrown away. So a row is now moved, not deleted
(Store.orphan → the orphans table), and the chat gets one card instead of
nine tombstones: «🔌 N sessions were stopped», with a button per session, «↩️
Restore all» and «🗑 Forget them». A restore is the ordinary launch path
(_create with --resume), so a session comes back under its own name, and
«all» works through the list one at a time, waiting for each TUI — nine node
processes started at once are nine node processes fighting over one CPU, and a
directory Claude Code has not seen before stops on "Is this a project you
trust?".
Two signals tell a machine restart from a window somebody closed: the kernel's
boot id (/proc/sys/kernel/random/boot_id, kept in the meta table) has
changed, or the tmux server is not answering at all (tmux kill-server, no
reboot). One window disappearing on a live server is still an ending, and still
gets its tombstone. The offer expires after seven days, and it also goes away by
itself when the session is resumed from 🕘 history instead — Store.add clears
the row. The card scrolls away like any other message, so /sessions grows a
«🔌 Restore stopped sessions (N)» row for as long as any are waiting.
A restored session opens on an empty prompt. The context is all there, but work that was running at the moment of the crash is not picked up again, and a question that was on the screen is gone with the screen.
| Command | What it shows / does |
|---|---|
/service |
uptime, PID, code version (git hash + whether the tree is clean), tmux server PID, poll interval, number of managed sessions |
/restart |
restart the bot, with a confirmation; the same thing sits on a button in /service |
A restart is arranged as an ordinary exit: the bot stops polling and finishes,
and Restart=always brings it back in five seconds. That is why the button
appears only when the process really is a unit — checked through its own
cgroup rather than INVOCATION_ID, which the tmux server inherits and therefore
so does every session. A bot started by hand answers /restart with the
instructions for installing the unit and goes nowhere.
Who asked for the restart is recorded in ~/.cache/ccbot/restart.json, so once
it is back up the bot reports in that same chat: "✅ Back — the restart took N s."
A flag older than 10 minutes is ignored: it stands for a start that never
returned.
tmux attach -t ccbot \; select-window -t @Nccbot/screen.py is the only place that knows what the Claude Code interface
looks like. If the buttons stop appearing after a CLI update, take a fresh
sample:
tmux capture-pane -p -t @N > /tmp/dialog.txtand compare it against find_dialog().
A dialog is recognised by its footer, and there are two kinds of footer:
- choosing an option —
Enter to select · Esc to cancel; - a permission request (from a tool or a
PreToolUsehook) —Esc to cancel · Tab to amend · ctrl+e to explain. This one went unrecognised for a long time: the session sat quietly on "Do you want to proceed?" while the chat showed nothing.
The list of options is searched bottom-up, from the footer to the item "1.". Otherwise the parser latched onto the first numbered line on the screen — and Claude numbers its prose all the time, so the items of its own plan ended up on the buttons.
claude agents --json says waiting whatever the dialog looks like, which
makes it a more honest signal than the parser. When a session is waiting and
find_dialog() stays silent, the bot sends the screen as it is, with buttons
that work without any parsing: digits, arrows, ✅ and Esc. Staying quiet in that
situation is not an option.
- Streaming an answer.
sendRichMessageDraftexists for exactly the case this bot has — a reply that grows while Claude writes it — and would replace the "⏳ working" pulse with the text itself appearing. - Native tables. Claude's Markdown tables are already drawn by the client;
the bot's own lists (sessions, usage) are still
·-separated text and could becomeInputRichBlockTable. - Long plans.
ExitPlanModewith a large plan is truncated for now — it ought to be split across several messages.
MIT — see LICENSE. Contributions are welcome; a new interface
language needs no Python at all, only a .po file (see "Adding a language").