Telegram bot interface for coding agents (Claude Code + OpenAI Codex) on a VPS. Message the bot from any device, it runs the active agent in your project directories and answers in the chat. Every forum topic is a separate conversation, so several can run at once. Switch providers at runtime with /provider.
- Forum topics — every topic is its own conversation with its own session, project, queue and compose draft; topics run in parallel, up to
MAX_CONCURRENT_RUNS - Auto-named topics — a topic Telegram named for you is renamed from its first message, using the active provider's cheapest model
- Quiet by default — a run shows a typing indicator and then one message with the answer;
/verbosebrings tool calls, thinking and the footer back - SQLite state — topics, sessions, compose drafts and settings live in
.data/bot.db, with an optional nightly backup timer - Multi-provider — switch between Claude Code and OpenAI Codex at runtime via
/provider; sessions and capabilities are tracked per provider - Project switching — select any project directory via inline keyboard, auto-unpins old messages
- Streaming responses — real-time draft messages with edit-based fallback (verbose mode)
- Session continuity — follow-up messages continue the same conversation, per provider and per topic
- Message queuing — messages sent while that conversation is busy are queued and processed in order
- Thinking stream — the agent's thinking/reasoning content streamed in a separate message (verbose mode)
- Branch awareness — current git branch and open PRs shown in
/statusand response footers - Voice messages — voice notes transcribed via Groq Whisper, then sent to the agent as text
- Long response splitting — auto-splits messages exceeding Telegram's 4000 char limit
- Rich message rendering — model output sent as markdown, bot chrome as HTML, plain text as fallback
- Plan mode interception — Codex uses the
.codex/plans/convention; Claude'sExitPlanMode/.claude/plans/flow is supported but off by default (the hardened Claude settings deny plan mode — re-enable viaCLAUDE_SETTINGS_JSON). When active, the plan is presented for approval with options to execute (new/resume session), modify with feedback, or cancel - Capability-aware UI — cost/turns footer, thinking panel, and subagent messages adapt to what the active provider supports (e.g. Codex shows duration only)
- Hardened agent defaults — the Claude Agent SDK runs with a locked-down
Settingsprofile (plan mode + interactive/harness tools denied, bundled skills/remote-control/artifacts off,effortLevel: high); override any of it viaCLAUDE_SETTINGS_JSON - Wide-event observability — one structured JSON line per run appended to
.data/events.jsonl, queryable withbun run logs(no external infra) - Compose mode — collect multiple messages (text, voice, forwarded, files, photos) into a single prompt with
/composeand/send - Access control — single authorized user via Telegram user ID
- Bun runtime
- A Claude subscription login — the Claude Agent SDK is bundled as a dependency (no separate CLI install needed); it reuses your
~/.claudelogin, so authenticate once withclaude login - Codex CLI installed and authenticated (
codex login) — optional, only if you want the Codex provider - Groq API key — for voice message transcription
sqlite3onPATH— only for the nightly database backup, which usessqlite3 .backupbecause a plain file copy of a WAL database can miss committed writes (sudo apt-get install -y sqlite3)
Agent auth is login-managed: authenticate on the host once and the bundled binaries reuse it. No API key is required by default — subscription/CLI login is used (an optional ANTHROPIC_API_KEY fallback exists for Docker/CI, see Optional configuration).
Note: This bot uses the Claude Agent SDK (
query()). Starting June 15, 2026, paid Claude plans include a dedicated monthly credit for programmatic usage (claude -p, Agent SDK, GitHub Actions). Usage draws from this credit first, then from optional usage credits at API rates. See Anthropic's announcement for details and credit amounts by plan.
- Message @BotFather on Telegram
/newbot→ follow prompts → copy the bot token
Forward any message to @userinfobot — it replies with your user ID.
cp .env.example .envEdit .env:
BOT_TOKEN=your_bot_token_here
ALLOWED_USER_ID=your_telegram_user_id
PROJECTS_DIR=/home/agent/projects
GROQ_API_KEY=your_groq_api_key
No OpenAI or Anthropic API key is required in .env — agent auth is handled by the login flow (see next step).
All optional, with sensible defaults:
| Env var | Default | Purpose |
|---|---|---|
ALLOWED_CHAT_ID |
(unset) | Forum supergroup id. Only the file-send script reads it: a run started in a topic sends files back to the group, and without this the send is refused |
CLAUDE_SETTINGS_JSON |
(hardened defaults) | JSON overlay of Claude Agent SDK Settings — override the default lockdown (see below) |
MAX_CONCURRENT_RUNS |
4 |
Global cap on concurrent agent runs |
RUN_TIMEOUT_MS |
(unbounded) | Per-run timeout in ms; unset means /stop is the only cancellation |
TG_LOG_FILE |
.data/events.jsonl |
Wide-event log path |
LOG_FORMAT |
pretty on a TTY, else logfmt |
pretty | logfmt | json |
LOG_LEVEL |
Info |
Minimum log level |
DRAFT_INTERVAL_MS |
300 |
Telegram draft update interval (ms) |
SPLIT_AT |
4000 |
Message split threshold (chars) |
ANTHROPIC_API_KEY |
(unset) | Optional API-key fallback for Docker/CI; unset keeps subscription login |
EXECUTOR_MCP_URL |
(unset) | Cloud Executor's org-scoped MCP endpoint (https://executor.sh/org_<id>/mcp) |
EXECUTOR_API_KEY |
(unset) | Executor API key; sent as Authorization: Bearer <key> |
Executor (external integrations over MCP). Optionally wire Executor into every agent run as an MCP server, giving the agent a single tool surface for external systems — Notion, Google Workspace, Vercel, Atlassian, and whatever else you connect. Set both env vars (endpoint and key are minted in the Executor dashboard); when either is blank the bot runs without Executor and nothing else changes. Both providers are wired: Claude via the Agent SDK's mcpServers, Codex via an mcp_servers config override.
Rather than exposing one tool per integration, Executor exposes a small set of meta-tools that surface to the model under the mcp__executor__* prefix:
| Tool | Purpose |
|---|---|
execute |
Run TypeScript in a sandboxed runtime; connected integrations are reachable as tools.<integration>.* |
resume |
Continue a paused, approval-gated execution via its executionId |
skills |
Fetch long-form how-to guidance (e.g. skills({name:"execute"})) kept out of the always-loaded tool descriptions |
Which integrations are available is configured in the Executor dashboard, not in .env; the execute tool description enumerates them at connect time.
EXECUTOR_MCP_URL=https://executor.sh/org_xxx/mcp
EXECUTOR_API_KEY=exec_...Claude agent hardening. Every Claude run is passed a locked-down SDK Settings profile by default: plan mode (Enter/ExitPlanMode) and interactive/harness tools (AskUserQuestion, cron, remote/push, notebook, DesignSync, …) are denied, bundled skills / remote control / artifacts are disabled, and effortLevel is high; workflows stay on. Override any subset with CLAUDE_SETTINGS_JSON — top-level keys replace wholesale, permissions merges one level deep, and malformed JSON fails fast at boot. Examples:
CLAUDE_SETTINGS_JSON='{"permissions":{"deny":[]}}' # clear all denials (re-enables Claude plan mode)
CLAUDE_SETTINGS_JSON='{"effortLevel":"medium"}' # lower effort, keep every other defaultLog into each coding-agent CLI once on the host (these persist for the bot):
claude login # Claude Code
codex login # Codex — optional, only if you want the Codex providercodex login supports ChatGPT (Plus/Pro/Team) or an OpenAI API key, via a browser-based flow. On startup the bot runs a non-blocking codex login status check and only warns if Codex isn't logged in — Claude-only hosts still boot fine.
bun install
bun run src/index.tsThis runs the bot in the foreground — fine for a first check, and bun run dev auto-reloads on changes during development. For any real deployment, run it as a systemd service (next section): it auto-restarts on crashes and survives reboots.
To keep the bot running across reboots and auto-restart on crashes, install it as a systemd user service. One command detects your paths and sets everything up:
bun run service:installThis detects the bun binary (process.execPath), the repo directory, your .env, and the PATH dirs holding bun/claude/codex; generates ~/.config/systemd/user/telegram-claude.service; runs daemon-reload; enable --now; and enables linger so the bot survives logout and starts at boot. It is idempotent — re-run it any time (e.g. after bun upgrade moves the binary) and it reconciles the unit, restarting only if the definition changed.
Preview the generated unit without writing anything:
bun run service:install --dry-runAdd --with-backup to also install the nightly database snapshot timer (see State and backups):
bun run service:install --with-backupOperations:
| Command | Description |
|---|---|
bun run service:status |
Show active/enabled/linger and backup-timer state (--json for a machine-readable blob) |
bun run service:logs |
Follow the journal (-n <N> scrollback, --no-follow to tail once) |
bun run service:backup |
Take one database snapshot through the systemd unit, then show its journal |
bun run service:restart |
Restart the service |
bun run service:start |
Start the service |
bun run service:stop |
Stop the service |
bun run service:uninstall |
Disable, stop, and remove the units (leaves .env, linger and existing snapshots untouched) |
If loginctl enable-linger needs privilege on your host, install prints the exact sudo loginctl enable-linger $USER to run and continues. The raw systemctl --user … telegram-claude / journalctl --user -u telegram-claude -f commands still work for anyone who prefers them.
Alternative: tmux. If you'd rather not use systemd, run the bot in a detached tmux session — tmux new-session -d -s telegram-claude 'bun run src/index.ts' — and reattach with tmux attach -t telegram-claude. It survives SSH disconnects but won't auto-restart on crashes or come back after a reboot, so prefer the service for anything long-lived.
git pull
bun install
bun run service:restartThe first boot after the upgrade creates .data/bot.db and imports the old JSON stores into it — active project, provider, per-provider model and effort choices, and every session id in state.json and sessions.json. The import is guarded by a flag inside the database, so it runs once; the JSON files are left on disk untouched and are never read again. Topics need nothing: each one pins itself the first time you write in it.
Re-run bun run service:install --with-backup if you want the nightly snapshot timer. Install is idempotent, so re-running it is safe.
Each forum topic is a separate conversation. It pins its own project and provider, keeps its own session id, its own message queue and its own compose draft, and its run has its own slot — so topics work in parallel, bounded only by MAX_CONCURRENT_RUNS. A chat without topics, a plain group and the forum's General topic each stay one conversation, exactly as before.
In your private chat with the bot. Turn topic mode on for the bot in @BotFather (/mybots → pick the bot → bot settings → topics). Your chat with it then opens as a list of topics rather than one thread. To confirm it took, getMe reports the flag:
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getMe" # "has_topics_enabled": trueIn a forum supergroup. Create a supergroup, turn Topics on in its settings, then add the bot as an administrator with permission to manage topics. Behaviour is identical to private-chat topics. Two BotFather switches matter here and no API can change them: privacy mode must be disabled (/setprivacy) or the bot never sees plain messages in a topic, and group membership must be enabled (/setjoingroups). The bot logs a warning at startup for either.
Naming. A topic you name yourself is left alone. When Telegram names it for you, the bot renames it from the first text or voice message you send, using the active provider's cheapest model — one turn, no tools, no session file, and silently dropped if it fails. Renaming the topic yourself before that first message also wins.
Commands inside a topic. /stop and /new affect that topic alone. /projects re-pins the topic to the chosen project — starting a fresh session there — and also becomes the default for new conversations; /provider behaves the same way. Other topics keep the project and provider they are pinned to. /verbose is the one global switch: it applies to every conversation at once.
| Command | Description |
|---|---|
/projects |
Select active project directory |
/provider |
Switch coding agent provider (Claude Code / Codex) |
/model |
Switch model within the active provider |
/effort |
Switch reasoning-effort level within the active provider |
/history |
Browse and resume past sessions |
/stop |
Kill running agent process |
/status |
Show active project, provider & process state |
/new |
Clear session, start fresh conversation |
/compact |
Summarize the active session in place, keeping it (Claude Code only) |
/verbose |
Toggle tool calls, thinking and the metadata footer on or off |
/compose |
Start collecting messages into a batch |
/send |
Send all composed messages as one prompt |
/cancel |
Cancel compose mode, discard messages |
/branch |
Show current git branch |
/pr |
List open pull requests |
/help |
Show available commands |
Text messages are forwarded to the active coding agent as prompts. Voice messages are transcribed and forwarded the same way.
Output is quiet by default: a run shows a typing indicator and then one message with the answer. Tool calls, thinking, sub-agent messages and the metadata footer (project, cost, time, turns) never reach the chat, and no draft previews are streamed — a long answer is still split across messages, and an error always shows. /verbose toggles the full dev view back on and reports which mode you landed in. The choice is stored in .data/bot.db and applies to every conversation.
Use /provider to pick between Claude Code and OpenAI Codex via an inline keyboard. The choice is global, persisted across restarts, and switching auto-stops any running process. The active provider is shown in /status, /help, the pinned project message, and the startup message. Sessions are tracked separately per provider, so /history and follow-up continuity stay scoped to whichever provider is active.
Long conversations eventually fill the context window. /compact summarizes the active project's session for the active provider in place: the session id is unchanged, so the next message continues the same conversation instead of starting over as /new would. A run in flight is stopped first (as with /new), and the reply quotes the context size before and after when the provider reports it.
Only Claude Code supports it — the Agent SDK reaches the CLI's own compaction by sending /compact as the prompt for the resumed session, and reports the result on a compact_boundary message. The Codex SDK (0.155.1) exposes no compaction API, so /compact declines for Codex and points at /new rather than clearing anything.
Use /compose to batch multiple messages into a single prompt. Useful for forwarding context from other chats, combining voice notes with text, or building multi-part requests. All message types are supported: text, voice (auto-transcribed), forwarded messages, files, and photos. Send /send when done or /cancel to discard.
- Runs the active provider in the selected project dir and normalizes its streaming output into a provider-agnostic event model
- Claude Code: the Agent SDK
query()runs in-process (no CLI spawn); it bundles its own Claude binary and reuses~/.claudelogin - Codex: spawns
codex exec --json(resume viacodex exec resume <id>)
- Claude Code: the Agent SDK
- Quiet by default: a typing indicator while the run works, then the answer. In verbose mode the response is also streamed back as drafts (~300ms interval), falling back to progressive message editing if drafts aren't supported
- Long responses auto-split into multiple messages (4000 char limit)
- Follow-up messages continue the same session for the active provider (Claude via the SDK
resumeoption, Codexexec resume <id>); a forum topic keeps its session id on its own row, everything outside a topic keeps one session per project per provider - UI features adapt to provider capabilities — Codex omits cost/turns (duration only) and subagent messages; both stream thinking
- Voice notes are transcribed via Groq Whisper (
whisper-large-v3-turbo) - When
EXECUTOR_MCP_URL+EXECUTOR_API_KEYare set, cloud Executor is attached to both providers as an MCP server, so the agent can reach external integrations through itsmcp__executor__*meta-tools - One run at a time per conversation (across providers); a message sent while that conversation is busy is queued, while other topics keep running in parallel up to
MAX_CONCURRENT_RUNS - Plan mode is organic for both providers: Claude writes to
.claude/plans/and callsExitPlanMode; Codex follows the.codex/plans/PLAN.mdconvention it's taught via an injected prompt prefix. Either triggers the same interception — the bot displays the plan as plain text and offers action buttons: execute in a new session, execute keeping context, or modify with feedback - Use
/stopto cancel the current process and clear the queue
Everything the bot remembers lives in .data/, next to the code. It is git-ignored and the only thing worth backing up.
| File | Holds |
|---|---|
.data/bot.db |
SQLite: forum topics (project pin, provider, session id), per-project sessions, compose drafts, and settings — active project and provider, model and effort per provider, verbosity |
.data/events.jsonl |
one JSON line per run (see Observability) |
.data/backups/ |
nightly bot.db snapshots, once you install the timer |
.data/state.json, .data/sessions.json |
legacy JSON stores. Imported into bot.db once on first boot, then left in place and never read again |
The database runs in WAL mode, so bot.db-wal and bot.db-shm sit beside it.
Two more systemd user units: telegram-claude-backup.service takes one snapshot, telegram-claude-backup.timer fires it daily with Persistent=true, so a run missed while the machine was down is caught up. Install and check them:
bun run service:install --with-backup
systemctl --user list-timers telegram-claude-backupEach run writes .data/backups/bot-YYYYMMDD.db.gz and deletes snapshots older than 14 days; set BACKUP_KEEP_DAYS in .env to change the window. The snapshot is taken with sqlite3 .backup, so sqlite3 must be on PATH. Take one by hand:
bun run service:backup # through the installed unit, then prints its journal
bun run backup # the script directly, no systemdbun run service:status shows the timer's state next to the bot's.
bun run service:stop
gunzip -c .data/backups/bot-20260901.db.gz > .data/bot.db
rm -f .data/bot.db-wal .data/bot.db-shm
bun run service:startDeleting the stale -wal and -shm files matters: they belong to the database you just replaced. events.jsonl and the legacy JSON files are not in the snapshot.
Each run appends exactly one structured JSON line to .data/events.jsonl — outcome, cost, tokens, duration, turns, provider, and project. Economics degrade to null (never a fabricated 0) when a run is interrupted or errors. Query it with no extra infra:
bun run logs # recent runs, aligned table
bun run logs:errors # only failed runs (error class + message)
bun run logs:stats # counts by outcome + total cost/time
bun run logs:follow # live tailThe path is overridable via TG_LOG_FILE, and LOG_FORMAT=json mirrors the same record to stdout.
TypeScript, Bun, Effect, grammy, Claude Agent SDK, Groq SDK
Contributions welcome! See CONTRIBUTING.md for guidelines.