claude-telegram-bot
v0.3.6
Published
Drive Claude Code from Telegram — messages run headless `claude -p` in a project dir and replies come back to the chat. Zero dependencies.
Maintainers
Readme
Claude Telegram Bot
한국어 · English
A zero-dependency, single-file, daemonized Claude Code bot — no Bun, no Python, no open session.
A tiny bridge that takes your Telegram messages, runs claude -p (Claude Code headless mode)
in a project folder, and sends the result back to the chat. One .mjs file on Node 18+ built-ins —
nothing to npm install, nothing to audit but under 1 000 readable lines.
[you] → Telegram → bot.mjs → claude -p (config.projectDir) → result → TelegramDrive Claude Code from your phone: run tests, edit files, commit, push — all from a chat. It runs as a background daemon (launchd), so there's no interactive session to keep open.
⚠️ This is a remote code-execution tool by design. Read the Security section before running it.
A message you send from Telegram is executed as a command on the machine running the bot. With
permissionMode: bypassPermissions, a one-line message can run anything as your user.
Highlights
- Zero dependencies — just Node 18+. No npm install, no supply chain.
- Multi-project — one codebase drives many projects via per-project config files.
- Multi-persona — split the same project into role-based bots (e.g. Developer + Planner) with per-bot system prompts and differentiated permission levels.
- Session continuity — conversations resume across restarts (
--resume);/newto reset. - Attachments — send photos/docs/voice/video; they're saved locally and handed to Claude.
- Always-on — ships with a launchd template for macOS (auto-start, auto-restart).
How it compares
This space is crowded, and Anthropic now ships an official solution. Here's an honest map so you can pick the right tool:
| | This bot | Official Claude Code Channels | claude-code-telegram |
|---|---|---|---|
| Runtime | Node built-ins only | Bun + MCP plugin | Python 3.11+ + libs |
| Execution model | headless claude -p per message | events pushed into an open claude --channels session | Claude SDK / CLI |
| Stays running as | background daemon (no session open) | a live interactive session you keep running | service / daemon |
| Multi-persona, permission-scoped bots on one repo | yes (dev=bypass, planner=plan) | no | no |
| Per-action permission approval (inline buttons) | no (set permissionMode) | yes | partial |
| Feature breadth (webhooks, cron, voice, export) | minimal | medium | large |
| Lines of code to read/fork | ~400, one file | larger | large |
Use the official Channels if you want per-action approvals and don't mind keeping a session open. Use claude-code-telegram if you want maximum features. Use this if you want a minimal, auditable, zero-dependency daemon — and especially if you want role-split persona bots with different permission levels on the same project.
Security
Treat this tool like an SSH key into your machine that lives in a chat app. It is designed to execute commands; that power is the point, and also the risk. Read this before exposing it.
Threat model — who can run commands on your machine
- The allowed chat. Anyone with access to the Telegram account whose
chatIdyou allow can run commands. Lock your phone and Telegram account (2FA). - Whoever holds the bot token. The token is the bot's password. With it, an attacker can read
incoming messages and impersonate the bot. The
allowedChatIdwhitelist still blocks command execution (Telegram-suppliedchatIds can't be forged), but treat a leaked token as an incident: revoke it via@BotFather→/revokeand issue a new one. - Prompt-injected content. If you forward a webpage, file, or repo issue and ask the bot to act
on it, malicious instructions inside that content can steer Claude. Don't pipe untrusted content
into a
bypassPermissionsbot.
Non-negotiables
- Always set
allowedChatId. Until you do, the bot refuses to run anything and just replies with the chat's ID. Once set, only that chat can issue commands — this is your only auth layer, so it must be set. - Guard the token like a credential.
config.jsonandstate.jsonare in.gitignoreso you don't commit it — keep it that way. Never paste the token into issues, logs, or screenshots. The startup log redacts it (token: <redacted>); don't add it back. - There is no sandbox. The bot runs
claudeas your user, with your filesystem, your SSH/git credentials, and your Claude OAuth/keychain session. It can do anything you can.
Choose the least permission you can live with
permissionMode is the main safety dial:
| Mode | What it allows | Use when |
|---|---|---|
| plan | Read & plan only, no edits | Q&A, code review, a "planner" persona |
| acceptEdits | Auto-approve file edits; other actions (shell, etc.) still gated | Recommended default — useful but bounded |
| bypassPermissions | Everything auto-runs, including arbitrary shell | You accept that one chat message = arbitrary code execution |
Practical hardening:
- Prefer
acceptEditsoverbypassPermissionsunless you specifically need autonomous shell/git. - Point
projectDirat a specific project, not your home directory — limit the blast radius. - For multi-persona setups, give only one bot
bypassPermissions; keep the rest onplan. - Consider running on a dedicated user account or VM if you'll leave it always-on.
Reporting a vulnerability
Found a security issue? Please open a GitHub issue (or contact the maintainer privately for sensitive reports) rather than posting exploit details publicly.
Install & run
This is a standalone CLI/daemon, not a library — you don't import it. Install it globally (or
run via npx), point a config file at any project, and run it. projectDir in the config decides
which folder Claude works in, independent of where the bot is installed.
Prerequisites: Node 18+ and the claude CLI installed and authenticated on the host.
Option A — npx (no install)
npx claude-telegram-bot init # writes ./mybot.json
npx claude-telegram-bot init myapp.json # or pick your own filename
# edit the config (token, projectDir, …)
npx claude-telegram-bot # runs ./mybot.json (falls back to config.json)Option B — global install (recommended for an always-on daemon)
npm i -g claude-telegram-bot
claude-telegram-bot init ~/botconfigs/myproj # writes ~/botconfigs/myproj/mybot.json
claude-telegram-bot init ~/botconfigs/myproj/myapp.json # or a custom filename
# edit the config (token, projectDir, …)
claude-telegram-bot ~/botconfigs/myproj/mybot.jsonRun several projects/personas by making one config file each and passing its path —
state.json and attachments/ live next to that config, so they don't mix.
Keep your config out of git. The config file holds your bot token. If you drop one inside a git repo, add it (plus
state*.jsonandattachments/) to that project's.gitignore. This repo already ignoresconfig.json,config.*.json,*.config.json,state*.json, andattachments/, so any name likeclaudebot.config.jsonis covered here — but your own project won't ignore them until you say so.
First-run steps
1) Create a bot token — In Telegram, open @BotFather → /newbot → pick a name and a
username ending in _bot → copy the token (looks like 123456789:AA...). Put it in config.json,
leave allowedChatId empty for now.
2) Find your chatId and lock the bot to it — Start the bot (claude-telegram-bot …), send it any
message in Telegram; it replies with this chat's chatId. Put that number into mybot.json
allowedChatId and restart. Now only you can use it. (See Security — this is your only
auth layer.)
3) Use it — just send messages:
run the solver tests and commit + push if they passadd an edge case to solve-2nd-floor-edges.ts
Commands: /new (reset context / new session) · /stop (stop current task; --reset to also roll back the session) · /cron (list / add / remove scheduled tasks) · /restart (syntax-check & restart the bot) · /status (bot status & version) · /model (view / switch the model) · /id (show chat ID) · /help.
/stopkills the running Claude process immediately and clears any queued messages. Add--resetto also restore the session to the state it was in before the task started, so the conversation history doesn't include the interrupted work.
/restartrunsnode --checkonbot.mjsfirst and aborts the restart if it has a syntax error (so a bad edit can't crash-loop the bot), then exits — relying on a process supervisor to relaunch it. Works out of the box with the launchd setup (KeepAlive); under a barenode bot.mjswith no supervisor it just stops. Your session resumes after the restart (the id lives instate.json).
4) Keep it always on (optional) — see Always-on with launchd.
From source (for hacking on the bot): clone the repo,
cp config.example.json mybot.json, thennode bot.mjs [mybot.json]. Same behavior as the CLI.
Configuration
cp config.example.json mybot.jsonEdit mybot.json:
| Key | Description |
|---|---|
| token | Bot token from BotFather |
| allowedChatId | Leave empty at first → the bot tells you (step 3). Required before it runs anything. |
| projectDir | Absolute path to the working folder Claude runs in |
| claudeBin | Output of which claude (absolute path recommended) |
| permissionMode | plan / acceptEdits / bypassPermissions — see Security |
| model | Empty = default. Or opus / sonnet, etc. Override at runtime with /model (persists in state). |
| lang | (optional) UI language. Empty = auto-detect per user (English default, Korean for Korean Telegram clients). Force with "en" / "ko". |
| name | (optional) Bot name shown in /help — handy for telling multiple bots apart |
| persona | (optional) Role system prompt — defines a persona (developer/planner/…). See below |
| appendSystemPrompt | (optional) Override the default "be concise for Telegram" instruction |
| env | (optional) Extra environment variables passed to the claude process |
| schedule | (optional) Cron jobs that run a prompt on a timer — see Scheduled tasks |
State and downloaded attachments live in a hidden .claude-bot/ folder next to the config
file, so projects stay isolated. Upgrading from an older version auto-moves an existing
state.json / attachments/ into .claude-bot/ on first start (no data loss). Logs stay wherever
your launchd plist points them.
Usage details
- Concise mode: a
--append-system-promptis applied by default so replies stay short for Telegram. Override it viaappendSystemPrompt(empty string disables it). - Language: the bot's own messages (
/help, command menu, status text) are English by default and switch to Korean for users whose Telegram client is Korean. Force one language withlang("en"/"ko"). Claude's actual replies follow the language you write in, regardless. The/command menu is registered per-language viasetMyCommands. - Formatting: the reply's Markdown (bold/code/headings/tables) is converted to Telegram-safe HTML. If conversion ever produces invalid HTML, the message is automatically resent as plain text.
- Attachments: send a photo/document/voice/video and it's downloaded into
attachments/; the absolute path is handed to Claude (caption included as the message). Images can be opened with Read. - Sessions: conversations resume automatically (
--resume); the last session id is saved instate.json, so context survives restarts. Use/newto start fresh. - Message queue: if you send a message while a task is running, it is queued (not dropped). When the task finishes, all queued messages are merged into a single prompt so Claude can resolve corrections and follow-ups in one pass (e.g. "do X" then "never mind, do Y" → handled together). Use
/stopto cancel the running task and discard the queue. - Model hint: the bot tells Claude which model it is running as. If Claude judges a question to be beyond its current tier, it appends a one-line suggestion at the end of the reply (e.g. 💡
/model sonnet). Switch with/model <name>—haiku,sonnet,opus,fable, or a full model id. The choice persists instate.jsonacross restarts.
Scheduled tasks (cron)
Add a schedule array to the config to run prompts on a timer — daily briefings, periodic
checks, reminders. Each entry runs the prompt and sends the result to allowedChatId.
"schedule": [
{ "cron": "0 9 * * 1-5", "label": "Morning brief", "prompt": "Summarize today's open issues and TODOs" },
{ "cron": "*/30 * * * *", "prompt": "Check CI status; only reply if something is red" }
]cron— standard 5-field expressionminute hour day-of-month month day-of-week(e.g.0 9 * * 1-5= 09:00 on weekdays). Supports*, lists (1,3,5), ranges (1-5), and steps (*/15). Day-of-week0and7both mean Sunday. Times use the host's local timezone. No external dependency — the parser lives inbot.mjs.prompt(required) — the message sent to Claude.label(optional) — a short name shown in the reply footer and in/cron.- Fresh session: scheduled jobs run in their own session so they never pollute your
interactive conversation context (
state.jsonstays yours). They share the single-task lock, so a job is skipped (logged) if a task is already running when it fires. - Silent jobs (conditional alerts): if Claude's output is empty or exactly
SKIP, that run sends nothing to Telegram. To get "alert only when it matters, stay quiet otherwise," tell the prompt to output justSKIPwhen the condition isn't met. This lets even frequent jobs (e.g. every 5 minutes) run without spamming the chat.
Add jobs from the chat — in plain language:
/cron add summarize open issues every weekday at 9amThe bot asks Claude to turn that into a cron expression, echoes back what it understood
(so you can catch a misread), and saves it to state.json — no restart needed. Dynamic
jobs get an id; manage them with:
/cron— list everything (config jobs are tagged[config]; dynamic ones show#id)/cron add <plain-language request>— e.g./cron add every 30 min, ping me if CI is red/cron rm <id>— remove a dynamic job (config jobs are edited in the file)
Config-defined jobs still require a restart to change; only chat-added jobs are live.
Running multiple projects
The code is project-agnostic: make one config file per project and run several at once.
- Run:
node bot.mjs /absolute/path/to/project.config.json(no arg →./mybot.json, fallback./config.json) state.jsonandattachments/live in the config file's folder, so projects don't mix.- Note: Telegram allows only one poller per token → each project needs its own BotFather token.
- For always-on, copy the launchd template per project (see below).
Example — two projects:
~/projects/A/claudebot.config.json (token A, projectDir=~/projects/A)
~/projects/B/claudebot.config.json (token B, projectDir=~/projects/B)
node bot.mjs ~/projects/A/claudebot.config.json # instance A
node bot.mjs ~/projects/B/claudebot.config.json # instance BMultiple personas (roles)
You can split the same project into role-based bots (e.g. Developer + Planner). One codebase, a separate config file per role.
persona: a role system prompt in the config becomes that bot's identity. The concise-Telegram instruction is injected automatically, sopersonaonly needs the role itself.- Differentiated permissions via
permissionMode: since the bots share a folder, keep the shell-using bot (bypassPermissions) to just one to avoid concurrent-edit conflicts. For read/plan-only, useplan. - Session isolation: the
statefilename is derived from the config name (mybot.json→mybot.state.json,dev.config.json→dev.config.state.json), so multiple configs in one folder don't share context. - One token per bot: each bot needs its own BotFather token (
allowedChatIdcan be the same).
Example — Developer + Planner:
dev.config.json (permissionMode: bypassPermissions, persona: "Senior developer...")
planner.config.json (permissionMode: plan, persona: "Product/UX planner...")
node bot.mjs dev.config.json
node bot.mjs planner.config.json| Bot | permissionMode | Role |
|---|---|---|
| Developer | bypassPermissions | Implement, edit, test, git |
| Planner | plan (read/plan only) | Feature proposals, specs, UX direction |
For always-on, copy
com.claudebot.example.plistper bot and register each with a distinctLabel, config argument, and log paths (see below).
How to run
| Method | When terminal closes | After reboot | On crash | Use for |
|---|---|---|---|---|
| node bot.mjs | stops | ✗ | ✗ | testing, finding chatId |
| nohup node bot.mjs > bot.log 2>&1 & | survives | ✗ | ✗ | quick background run |
| launchd (LaunchAgent) | survives | ✅ auto-start | ✅ auto-restart | always-on (recommended) |
node bot.mjs &also backgrounds it, but closing the terminal kills it (SIGHUP). Usenohupto survive that, and launchd to survive reboots/crashes.
Always-on with launchd (macOS)
Keeps the bot alive across reboots and crashes. It runs as a LaunchAgent in your login session, so it reuses Claude's keychain/OAuth auth.
1. Check the plist (paths / node version)
com.claudebot.example.plist assumes certain paths — fix them first if yours differ:
which node # must match the node path in ProgramArguments
which claude # its directory must be on PATH (EnvironmentVariables)Items to verify in the plist:
ProgramArguments[0] — absolute path tonodeProgramArguments[1] — absolute path tobot.mjsWorkingDirectory— the project folderEnvironmentVariables > PATH— includes your node/claude directoriesStandardOutPath/StandardErrorPath— log file paths
2. Register & start
cp com.claudebot.example.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claudebot.example.plistModern macOS prefers
bootstrap/bootout. The oldload/unloadstill works but may print a deprecation warning. Ifbootstrapfails, fall back tolaunchctl load ~/Library/LaunchAgents/com.claudebot.example.plist.
3. Manage
launchctl list | grep claudebot # registered/running? (a PID means it's up)
tail -f bot.log # run log
tail -f bot.error.log # error log
# stop
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.claudebot.example.plist
# restart after editing code (bootout → bootstrap)
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.claudebot.example.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claudebot.example.plistTroubleshooting
launchctl listshows an error code with no PID → checkbot.error.log. Usually a node/claude path issue (command not found) or a missingconfig.json.- Bot doesn't respond → Claude auth may have expired. Run
node bot.mjsdirectly and confirmclaudeis logged in. - Mac is asleep → polling stops → disable sleep in System Settings > Battery/Power.
- Repeated "polling error" (ETIMEDOUT) → some networks block IPv6, so Node's fetch times out
against api.telegram.org (which has an IPv6 address).
bot.mjsalready works around this by preferring IPv4 (dns.setDefaultResultOrder('ipv4first')+ disabling auto-select). If it still fails, check the network/firewall withcurl https://api.telegram.org.
Requirements
- Node.js 18+ (for built-in
fetch) - The
claudeCLI installed and authenticated on the host - A Telegram bot token from
@BotFather
License
MIT © Jongtaek Choi
