Telegram Claude Bot — Operations Guide
Service: telegram-claude-bot
Host: bms-4 (54.36.123.110)
Type: N8N workflow + tmux session manager
Status: Active
Implements: GitHub issue #608
Architecture
The bot has two execution paths sharing one HTTP backend (claude-session-manager, port 9997):
Live path — /ask (plain messages and explicit /ask <question>):
[Telegram user: radieu]
|
| any message, or /ask <question>
v
[Telegram Bot API] ──webhook (HTTPS)──► [N8N BMS4]
Telegram Trigger → Check authorized user (AUTHORIZED_TELEGRAM_ID)
→ Route Command → "Exec: ask" httpRequest node (timeout 310000ms)
|
v
[claude-session-manager :9997, claude-runner, systemd]
ThreadingHTTPServer — each request runs in its own thread (#4002).
1. loads /home/claude-runner/telegram-worker/sessions/<chat_id>/<conv>.history.json
2. stitches last 60 exchanges into the prompt
3. background thread: Telegram sendChatAction=typing every ~4s
4. run_pgroup("claude --dangerously-skip-permissions -p ...",
cwd=/home/claude-runner/telegram-workdir/p24-infra, timeout=300s)
— Popen(start_new_session=True); on TimeoutExpired: killpg SIGTERM,
grace, then SIGKILL so no `claude` grandchild leaks to PID 1 (#4002)
5. under per-chat lock: appends the exchange back to the JSON history file
so concurrent messages to the same chat can't corrupt/lose history (#4002)
|
v
n8n receives the HTTP response → Send Telegram Reply
v
[Telegram user]
No persistent Claude session and no --resume — “memory” is entirely the JSON-stitched history
(60-exchange window, warns at 80 messages). The claude process is a brand-new one-shot subprocess
per message, exiting once it answers. The n8n node timeout (310000ms) must stay ≥ the subprocess
timeout (300s) — a shorter n8n timeout silently drops replies (see Incident Response below).
Legacy path — /start /stop /restart /send /log (issue #608’s original design):
[Telegram user] → /start /stop /restart /send /log → [N8N] → Exec:<cmd> httpRequest node
→ claude-session-manager → tmux session "claude-agent" (persistent) → capture-pane output → reply
Still present and functional in server.py, but not what live traffic uses today — plain chat
and /ask bypass tmux entirely.
Components
| Component | Location | Purpose |
|---|---|---|
| N8N workflow | BMS4 N8N instance (telegram-claude-bot, id c0cRdAA8vJc9wjIE) | Telegram webhook → command routing → HTTP call to claude-session-manager |
| claude-session-manager | BMS4, systemd service, port 9997, as claude-runner | Owns both the /ask one-shot-subprocess path and the legacy tmux path |
| Dedicated Claude workdir | /home/claude-runner/telegram-workdir/p24-infra | cwd for every /ask subprocess — isolated from the queue-worker checkout at /opt/p24-infra (#3708) |
| Per-chat history | /home/claude-runner/telegram-worker/sessions/<chat_id>/ | JSON conversation history + index, keyed by Telegram chat ID and named conversation |
| tmux sessions (legacy) | BMS4, as claude-runner | Persistent Claude CLI session used only by /start /send /log /stop /restart |
| setup.sh | infra-src/telegram-claude-bot/setup.sh | One-shot BMS4 configuration |
| sudoers snippet | /etc/sudoers.d/n8n-claude-runner | Allows N8N to run commands as claude-runner (legacy path) |
Environment file (/opt/claude-session-manager/.env)
claude-session-manager.service loads /opt/claude-session-manager/.env (root:root 0600) via
EnvironmentFile=. server.py reads CLAUDE_SESSION_MANAGER_KEY (auth), TG_BOT_TOKEN, and
SUPABASE_SERVICE_ROLE_KEY / SUPABASE_URL (its own Supabase queries; every child session it
spawns also inherits these in process env).
CLAUDE_SESSION_MANAGER_KEY,SUPABASE_SERVICE_ROLE_KEYandSUPABASE_URLare all kept in lockstep with the canonical/opt/p24-infra/bms-4/.envbysecrets-sync.yml’ssync-bms-4job (#6573): after the canonical.envis installed, the job idempotently rewrites only those three keys in this file, re-assertsroot:root 0600, and restarts the service only when a value changed.bms-4/.envis the single source of truth — do not hand-edit these keys here; other keys (e.g.TG_BOT_TOKEN) are preserved. Before #6573 this file had no distribution path, so a Supabase key rotation (legacy JWT →sb_secret_*) left it holding the revoked key — breakingserver.py’s Supabase auth and leaking the stale key into every spawned child session’s env.CLAUDE_SESSION_MANAGER_KEYhad the same drift class:scripts/rotate-credentials.py::rotate_claude_session_manager_key()writes it to the canonical.envbut never to this EnvironmentFile — now covered by the same refresh.- This refresh rides the same trigger as the drift source: any
secrets/n8n-bms4.env.sopschange (which is whereSUPABASE_SERVICE_ROLE_KEYis rotated) retriggerssync-bms-4. To apply an already-landed rotation immediately without waiting for the next change:gh workflow run secrets-sync.yml --repo radieu/p24-infra -f target=bms-4(sys-admin action — also restarts n8n on bms-4).
Bot Commands Reference
| Command | Mechanism | Expected output |
|---|---|---|
/ask <question> or plain message | one-shot claude -p subprocess, cwd = dedicated workdir, JSON-stitched history, up to 300s | Claude’s answer (n8n node timeout 310000ms) |
/conversations | list named conversations for this chat | numbered list, active one marked |
/conversation <name|number> | switch active conversation | ”Switched to conversation: X” |
/new-conversation [name] | start a new, empty conversation | ”New conversation started: X” |
/new-conversation-reset-history | clear history for the active conversation | ”History cleared for conversation: X” |
/end-conversation-remove-history | archive + remove active conversation, switch to main | ”Conversation ‘X’ archived. Switched to main.” |
/context | message count + context-window usage | ”Messages: N / In context window: M/60” |
/status (legacy) | tmux list-sessions + ps aux grep claude + free -h | Session list, PID, CPU/RAM |
/start (legacy) | tmux new-session -d -s claude-agent -x 220 -y 50 | ”Session claude-agent started” |
/stop (legacy) | tmux kill-session -t claude-agent | ”Session claude-agent stopped” |
/restart (legacy) | kill + sleep 1 + start | ”Session claude-agent restarted” |
/send <prompt> (legacy) | tmux send-keys … Enter; sleep 5; tmux capture-pane -p | Claude output (last 30 lines) |
/log (legacy) | tmux capture-pane -p -S -50 | Last 50 lines of session |
/auth-worker <account> | start OAuth device flow for a claude-runner account (see Worker re-auth) | the one-time OAuth login URL to open in a browser |
/auth-code <code> | finish re-auth: paste the authorization code, verify, clean up | ✅ Re-auth verified OK for <account> or a ⚠️ failure summary |
Worker re-auth (/auth-worker + /auth-code, #6650)
Two commands that collapse the manual claude-oauth-reauth.md
Option A/B procedure (ssh root → tmux → claude auth login → scrape URL → paste code → verify →
cleanup) into two Telegram messages, so a dead OAuth token can be fixed from a phone without a
root terminal. They address the recurring re-auth latency behind #6613 / #6071 / #5290.
Flow
/auth-worker claude-runner-2→ the bridge SSHes toroot@localhost, startsclaude auth loginin a detachedtmuxsessionauth_<account>, polls the pane for the OAuth URL, and returns only that URL.- Open the URL in a browser, click Authorize, copy the returned code. (This browser step is the irreducible OAuth human anchor — it is deliberately never automated.)
/auth-code <code>→ the bridge pastes the code into the same session, waits, verifies with aclaude -p "say ok"round-trip, returns OK / FAIL only, and kills the tmux session. If exactly oneauth_*session is live the account is inferred; if two are live, disambiguate with/auth-code <account> <code>.
Security model (defense-in-depth)
- Account allowlist: only
claude-runnerandclaude-runner-2are accepted — enforced inserver.pyby_ACCOUNT_RE = ^claude-runner(-2)?$before the value reaches any command, so even a bypassed upstream gate cannot runclaude auth loginas an arbitrary--user. - Shell-injection guard: the authorization code must match
^[A-Za-z0-9_-]{4,120}$; both the account and the code are additionallyshlex.quote-wrapped. A value that fails validation runs nothing. - Single root chokepoint: all root escalation goes through the one audited
run_root()helper (ssh -i <root key> root@<host>; the bridge runs asclaude-runnerwith no passwordless sudo).run_root()is host-parameterised (defaultlocalhost) so a vps-i1 follow-up can reuse it — but vps-i1 uses a different (/proc-based) re-auth model and is out of scope for this first cut. - Secret-safety: only the one-time, non-secret OAuth URL is ever returned. The tmux pane is
never dumped wholesale (a narrow regex extracts only the URL), the verify step is summarized to
OK/FAIL, and
.credentials.json/ token values never touch output. - Upstream gates unchanged: these commands still ride the n8n
Check Authorized Userchat_id gate and the sharedX-Session-Keyheader —server.pyhas never done chat-level auth itself. - No orphans:
/auth-workerpre-kills any staleauth_<account>session and kills on timeout;/auth-codekills on completion — so an abandoned root-ownedtmux+claudeprocess is not left behind.
Host prerequisites (confirmed by a sys-admin step at deploy, per plan §2): the bridge user
has a root SSH key at ~/.ssh/vps_root_key (ssh -i ~/.ssh/vps_root_key root@localhost whoami →
root), there is no passwordless sudo, and runuser -l <account> -c 'claude auth login' works on
the host. The key path is overridable via the VPS_ROOT_KEY_PATH env var.
Security Model
- User allowlist: N8N workflow checks
message.from.idagainstAUTHORIZED_TELEGRAM_IDenv var - No response to unauthorized: messages from other users are silently dropped (NoOp node)
- Credential storage: Telegram bot token lives in N8N credentials store only — never in git
- Process isolation: commands run as
claude-runner(non-root) via sudo - Sudoers scope: only
/usr/bin/bashis permitted, not all commands - Workspace isolation: Claude’s working directory is a dedicated clone at
/home/claude-runner/telegram-workdir/p24-infra, separate from/opt/p24-infra(the checkout shared by the autonomous queue-worker fleet) — seeserver.py’sWORK_DIRconstant (#3708)
EU AI Act — Risk Classification
- Risk tier:
limited(Art. 52 — transparency obligation only) - Rationale: This is a remote control interface for human-initiated Claude sessions. No autonomous decision-making, no scoring of persons, no automated allocation. The human (radieu) sends every prompt explicitly.
- Transparency: The bot is operated by the owner for personal infrastructure management.
Daily Operations
Check bot is responding
Send /status from Telegram. If no response within 30 seconds:
- SSH to BMS4:
ssh ubuntu@54.36.123.110 - Check N8N:
docker compose -f /opt/n8n/docker-compose.yml ps - Check workflow is Active in N8N UI
Start a Claude session
/start
Then send prompts with /send <your prompt>.
View live session output
/log
Note: /send waits 5 seconds for output. For long-running tasks, use /log after a delay.
Kill and restart session
/restart
Deployment / Update Procedure
server.py (claude-session-manager) — no CI auto-deploy, manual SSH step:
- Edit
infra-src/telegram-claude-bot/claude-session-manager/server.py, merge PR tomain - On Windows, extract the merged file with
[System.IO.File]::WriteAllText(...)(NOT PowerShell>/Out-File— those write UTF-16 with BOM and silently corrupt the Python source; this broke the #3708 deploy on first attempt) scpto bms-4,sudo cpinto/opt/claude-session-manager/server.py,sudo systemctl restart claude-session-manager- Verify:
curl http://localhost:9997/health→ok, andsystemctl show claude-session-manager -p MainPID,ActiveEnterTimestampshows a fresh PID/timestamp
n8n-workflow.json — two ways to apply, prefer the API for single-field changes:
- Fast path (single node field, e.g. a timeout): n8n REST API —
GET /api/v1/workflows/{id}, patch the specific node in the returnednodesarray,PUT /api/v1/workflows/{id}with only{name, nodes, connections, settings}. Auth:X-N8N-API-KEYheader, key =BMS4_N8N_API_KEYinsecrets/n8n-bms4.env.sops. Workflow id:c0cRdAA8vJc9wjIE. See/n8nskill for the full pattern. Always sync the repo’sn8n-workflow.jsonafterward (separate PR is fine) — the next manual re-import below would otherwise silently regress an API-only change. - Full re-import (structural changes, new nodes):
- Edit
infra-src/telegram-claude-bot/n8n-workflow.json, merge PR tomain - Open N8N (
https://n8n.bms-4.infra.zintegrowana.online), open workflowtelegram-claude-bot - Workflows → Import from file → overwrite existing
- Re-select credentials in both Telegram nodes (import does not carry credential bindings)
- Re-activate workflow
- Edit
Incident Response
After any
server.pychange lands onmain: the file has no CI auto-deploy. Follow “Deployment / Update Procedure” above to scp it to bms-4 and restartclaude-session-manager, orcurl http://localhost:9997/healthwill keep hitting the old code.
Bot not responding
- Check N8N workflow is Active
docker compose logs n8n --tail=100on BMS4- Restart N8N if needed:
docker compose restart n8n - Verify Telegram webhook: N8N Telegram Trigger node → Test Step
tmux session disappeared
- Sessions are lost on server reboot or manual kill (only affects the legacy
/start/send/logpath) - Just send
/startto recreate
Claude process hung (legacy tmux path)
/stop
/start
Bot shows “typing…” forever, never replies (/ask path)
Confirmed root cause (2026-07-11): journalctl -u claude-session-manager shows BrokenPipeError: [Errno 32] Broken pipe when the server tries to write the response. This means Claude did
finish and produce an answer, but n8n’s “Exec: ask” httpRequest node gave up and closed the
connection first because its options.timeout was shorter than the subprocess’s own 300s timeout
(150000ms vs 300000ms — n8n’s typing-indicator pings keep going via a separate direct call to the
Telegram API, so “typing…” shows even though the underlying request already died).
Fix: keep n8n’s “Exec: ask” node timeout ≥ the server.py subprocess timeout (currently
310000ms, matching the “Exec: wait” node). Verify: journalctl -u claude-session-manager --since "10 min ago" | grep BrokenPipe — any hits mean the mismatch is back.
Bot goes silent for minutes at a time (fixed 2026-07-12, #4002)
Historical root cause: server.py was single-threaded (HTTPServer(...).serve_forever()). One
slow /ask (up to 300 s) blocked EVERY other request, including /health. Users saw the bot as
“dead” until the current subprocess exited. Verify the fix is deployed:
curl -w '%{time_total}\n' -o /dev/null -s http://localhost:9997/health returns in <100 ms even
while an /ask is in-flight, and python3 -c "import ast; open('/opt/claude-session-manager/server.py').read()"
shows ThreadingHTTPServer in the file.
Zombie claude processes at PID 1 after timeout (fixed 2026-07-12, #4002)
Historical root cause: subprocess.run(..., timeout=300) killed only the sh -c wrapper, leaving
the claude grandchild to reparent to init and burn ~274 MB RSS + Anthropic tokens per orphan.
server.py now uses run_pgroup(...) which launches the shell in a new process group
(start_new_session=True) and os.killpg on timeout. Verify: after a /ask timeout, run
ps -eo ppid,comm | awk '$2=="claude" && $1==1' — output must be empty.
Related
- Setup guide:
infra-src/telegram-claude-bot/README.md - N8N workflow:
infra-src/telegram-claude-bot/n8n-workflow.json - Setup script:
infra-src/telegram-claude-bot/setup.sh - BMS4 operations:
docs/servers/p4-ovh-bms-4-ns3101999-operations.md - GitHub issue: https://github.com/radieu/p24-infra/issues/608