---
name: telegram-claude-assistant
description: Production patterns for building a personal Telegram assistant powered by claude -p (Claude Code CLI) on an always-on home server. Covers session-per-chat architecture, runtime-editable business logic, output contracts (bubbles, SEND_FILE), never-fail-silently error handling, systemd deployment traps, model routing, hard read-only guardrails, and the special dangers of self-modifying bots. Use this skill when building any Telegram bot that delegates reasoning to an LLM CLI over personal data.
license: MIT
compatibility: Claude Code, any LLM-based coding agent
metadata:
  author: Paulo Silveira
  version: "1.0"
---

# telegram-claude-assistant — Personal Assistants over `claude -p`

Practical guide for building Telegram bots where the bot process is a thin router and the intelligence is `claude -p` (Claude Code CLI) spawned per message. Based on three production assistants running on one home server: a home-automation bot, a family secretary over a document folder, and a personal PKM/notes bot. Every rule below cost at least one real bug.

## Architecture: thin bot, sessions per chat

The bot process does Telegram plumbing only: long polling, allowlist, routing, sending. Anything requiring judgment becomes a subprocess call:

```python
cmd = [CLAUDE_BIN, "-p", "--model", MODEL,
       "--output-format", "json",
       "--append-system-prompt", STYLE_PROMPT,
       "--permission-mode", "bypassPermissions"]
if session_id:
    cmd += ["--resume", session_id]
cmd.append(prompt)
proc = subprocess.run(cmd, cwd=WORKSPACE_DIR, capture_output=True, text=True, timeout=300)
```

- **Session per chat**: persist the `session_id` from the JSON output, pass `--resume` next time. The chat keeps context across messages without you managing history.
- **`--append-system-prompt` is sent on every call**, so contract changes apply immediately, even to resumed sessions. No redeploy of "the prompt".
- **`bypassPermissions` only for a single trusted user** behind a strict chat-ID allowlist. Group chats: ignore silently unless explicitly designed for them.
- **One lock per chat**: serialize claude calls per chat (`asyncio.Lock`), or two quick messages will race the same session.
- Send `typing` chat action on a loop while claude runs, and a "still working" message after ~1 minute. Silence reads as death.

## Business logic lives in files, not code

Set `cwd` to a workspace folder whose `CLAUDE.md` holds the assistant's context, conventions, and guardrails. For a bot over documents, keep the filing conventions (naming, schemas, folder map) in Markdown *next to the data*. The session reads them at runtime. Changing behavior means editing a text file, not redeploying code.

Corollary: the folder needs an index the model checks before claiming something does not exist. A generated `_INDEX.md` with per-file content previews turns "not found" into an actual search.

## Output contract (the model writes for Telegram)

Teach the format in the system prompt and enforce it in the bot:

- **Bubbles**: model separates independent blocks with a line containing only `---`; the bot splits and sends each as its own message. Chunk anything over ~4000 chars (Telegram limit is 4096).
- **HTML subset only**: `<b> <i> <code> <pre>`. No markdown tables, no `#` headers, no code fences. Fall back to plain text on `BadRequest` (model HTML occasionally breaks).
- **Attachments**: model emits `SEND_FILE: /absolute/path` on its own line, one per file. The bot strips these lines, sends the files as documents, and warns in-chat if a path is missing. Tell the model: "when asked for a document, send it, do not describe it."
- **Messages to another chat** (e.g. a family group): same pattern, `SEND_GROUP: <text>`, only on explicit request, with delivery confirmation echoed back.
- **`callback_data` has a 64-byte limit.** Long slugs in inline buttons throw `BUTTON_DATA_INVALID` and, if unhandled, kill the *other* buttons too. Truncate and resolve by unique prefix on the handler side.

## Never fail silently

The default failure mode of a bot is dying quietly in the journal while the user stares at "typing...". Three layers:

1. **Register a global error handler** (`Application.add_error_handler` in python-telegram-bot). Any unhandled exception sends "internal error: <type>" to the chat (fall back to the owner's chat ID when there is no update). python-telegram-bot literally logs "No error handlers are registered" — that log line is a bug report.
2. **Catch `OSError` around the subprocess call.** A missing or non-executable binary must return an actionable chat message, not a traceback.
3. **Classify errors by what fixes them.** Timeout → "try something more specific". Auth expired (match `oauth`/`authenticate`/`credential` in stderr) → "human action needed on the server", because retrying cannot help. Generic failure → show detail plus a Retry button.

Also: exceptions inside `asyncio.create_task` background tasks die mute. Wrap and log.

## systemd and deployment traps

- **`CLAUDE_BIN` must be an absolute path in `.env`.** systemd's default PATH does not include `~/.local/bin`, where the Claude CLI installs. A bot that works for weeks dies on the first reboot with `FileNotFoundError: 'claude'`. Test it: run the runner with `env -i PATH=/usr/bin:/bin`.
- **Use a long-lived `CLAUDE_CODE_OAUTH_TOKEN`** (from `claude setup-token`) in the environment. The interactive OAuth session in `~/.claude` expires and cannot refresh headlessly.
- **Deploy = checkout-run**: the server runs a git checkout; deploy is `git pull && systemctl restart`. Give the bot's own checkout a *read-only* deploy key (see self-modification below).
- **Restart amnesia**: any in-memory conversational state (pending previews, "adjust this draft" flows) dies on deploy. Persist it to a JSON file on every mutation and reload on startup. A user mid-flow when you deploy will otherwise get silent nothing.
- **Never run the app server with `--workers > 1`** if state is in memory: each worker gets its own copy. Add a test that fails if the unit file gains `--workers`.

## When to spend an LLM call, and which model

- **Deterministic commands stay deterministic**: `/agenda`, `/tasks`, morning reminder crons read APIs or YAML directly. Fast, free, predictable.
- **Free text gets routed by a cheap classifier first** (fast small model + regex heuristics): note vs. research request vs. plain conversation. Low confidence → show buttons with the best candidate marked, let the human pick. Fail open to conversation.
- **Match model to judgment**: small model for classification/extraction, workhorse for most turns, frontier model only where deep judgment matters. Route trivial→small, hard→big.
- **Personal data is never a note/publish action.** Add a hard regex gate: bank accounts, ID numbers and the like always route to conversation, no matter what the classifier says. One "note this: my bank account is..." nearly became a public blog draft.

## Hard guardrails (not prompt suggestions)

Prompt-level rules bend under a convincing request. The rules that matter are enforced in code:

- **Read-only by default, in the client layer.** The Google/API client simply has no delete or update functions, plus a test that fails if someone adds one. "The model promised not to delete" is not a guardrail.
- **Never bulk-delete, anywhere, ever** — no code path for it, confirmation is per item.
- **Backup before the first write feature ships.** Full calendar/tasks dump to a separate repo on a weekly timer, before the bot could write anything.
- **Reads are free; writes confirm; sends confirm.** Cheap reversible actions never prompt.
- **Money and passwords never pass through the bot.**
- **Secrets only in environment variables**, never in code or the repo.
- **Audit log**: one append-only JSONL, all writers, so there is one place to look when something happened at 11 pm.

## Self-modification: the loop that dissolves every other guardrail

The endgame of these assistants is closing the loop: you ask for a feature in chat, the agent writes the code, commits, deploys, and minutes later *is* the feature. We run pieces of this loop in production (the bot commits and pushes content to the blog repo on its own; features are built conversationally and deployed in minutes).

Treat self-modification as a different risk class, because **every guardrail above lives in code or prompt that a self-modifying bot can rewrite**. The read-only client, the confirmation flows, the allowlist, the audit log: all of it is one commit away from gone. Failure modes worth naming:

- A prompt injection (a document, a forwarded message) becomes a *permanent code change* instead of a one-off bad answer.
- A buggy self-patch can break the bot's ability to report that it is broken (see: never fail silently).
- Gradual drift: many small self-approved "improvements", none reviewed, add up to a system nobody understands.

Mitigations that keep the loop useful without handing over the keys:

1. **Split write access by repo.** Read-write deploy key only for *output* repos (the blog). The bot's *own* repo checkout gets a read-only key: it can read itself and propose a diff, but a human holds push and restart.
2. **Human approves the diff, not the idea.** The last mile of any change to the bot itself is a person reading the actual patch.
3. **Git history is the undo button** — which only works if the bot cannot force-push or rewrite history.
4. **Out-of-band recovery**: the ability to ssh in and restart/rollback must not depend on the bot being healthy.
5. **Protected paths**: guardrail code and contract files are the last thing you ever let an agent edit, and never in the same change as a feature.

Ship the loop for content first. Earn trust before pointing it at the bot's own code.

## Misc field notes

- **Whisper model choice is a product decision**: the turbo variant is fast but misses proper nouns and rare words; the full large-v3 takes ~2 min for a 3-min voice note but transcribes names right. Use full for dictated notes headed to publication, turbo for quick commands.
- **Telegram Mini App auth**: `secret = HMAC(key=b"WebAppData", msg=bot_token)` — official docs read as if it were the other way around. Enforce max-age on `auth_date`; no valid `initData` means a locked screen.
- **Webview cache**: serve the Mini App HTML with `Cache-Control: no-cache` or new UI elements will not appear while fresh API data does. A cached copy is per-URL: bump `?v=N` to bust it.
- **Log signal, not noise**: silence the HTTP client's per-poll INFO lines, hand-log the moments that matter.
