Ideas don’t wait for a desk.
I was on the road when I thought of a blog post I wanted to write. I had my phone, not my laptop. I wanted to send a message and have it just happen — Claude writes the post, formats it correctly, commits it to a branch, and waits for me to approve before anything goes live.
That’s what this bot does. Here’s how I built it and how it works.
The full flow
You send a message on Telegram
↓
bot.cjs receives it (running on your machine via PM2)
↓
Creates a git branch: feat/your-task-slug-timestamp
↓
Runs: claude --resume <session_id> -p "your message"
↓
Claude works in your project (writes files, creates posts, etc.)
↓
git add -A && git commit
↓
Bot sends approval notification:
[✅ Approve & Deploy] [📋 Diff] [❌ Reject]
↓
You tap Approve
↓
git merge → git push origin main → Vercel deploys
Live in ~30 seconds
Nothing reaches main without a deliberate tap.
Tech stack
| Layer | Choice | Why |
|---|---|---|
| Bot framework | telegraf v4 |
Modern, well-maintained, clean middleware API |
| Process manager | PM2 + pm2-windows-startup |
Auto-restart, survives reboots on Windows |
| Claude integration | claude -p / --resume |
Full project context (reads CLAUDE.md, codebase) |
| Session storage | JSON file (.claude-flow/data/) |
No database, survives bot restarts |
| Branch tracking | JSON file (.claude-flow/data/) |
Pending approvals persist across crashes |
The branch-per-task pattern
The core of the safety system. Every task that changes files gets its own branch. The withBranch helper handles the full lifecycle in one function:
async function withBranch(ctx, label, actionFn) {
const branch = `feat/${slugify(label)}-${timestamp()}`;
await runCmd('git', ['checkout', 'main']); // always start from main
await runCmd('git', ['checkout', '-b', branch]); // create feature branch
const result = await actionFn(branch); // run the actual work
const { out: statusOut } = await runCmd('git', ['status', '--short']);
if (!statusOut.trim()) {
// Nothing changed — delete the branch, return result directly
await runCmd('git', ['checkout', 'main']);
await runCmd('git', ['branch', '-D', branch]);
return { ...result, branched: false };
}
await runCmd('git', ['add', '-A']);
await runCmd('git', ['commit', '-m', `feat: ${label}`]);
await runCmd('git', ['checkout', 'main']); // always return to main
return { ...result, branched: true, branch };
}
The bot always returns to main after creating a branch. Without this, subsequent tasks would branch off the wrong base.
Persistent conversation across messages
Each Telegram chat maps to one Claude session UUID. The first message starts a fresh session, captures the new UUID from .claude/sessions.log, and stores it:
// Before running claude, snapshot what's already in the log
const beforeIds = sessionIdsInLog();
// Run the prompt
const result = await runCmd('claude', ['-p', prompt], { timeout: 300000 });
// Wait 800ms — the SessionStart hook writes async
await new Promise(r => setTimeout(r, 800));
// Find the UUID that wasn't there before
const sid = newSessionId(beforeIds);
if (sid) setSession(chatId, sid);
Every subsequent message uses claude --resume <uuid> -p "<message>". Claude remembers everything from earlier in the conversation — context across messages, across sessions, from wherever you are.
If a session expires, the bot detects the failure and tells you:
⚠️ Previous session expired
Old: `4a0096a2-...`
New: `e5f6g7h8-...`
You’re never silently on a stale session.
Setting up on Windows
# Inside your project
cd telegram-bot
npm install
# Install PM2 and Windows startup handler
npm install -g pm2
npm install -g pm2-windows-startup
pm2-startup install # writes a registry key — PM2 runs at Windows login
# Fill in credentials
copy .env.example .env
# Edit .env:
# TELEGRAM_BOT_TOKEN — from @BotFather on Telegram
# TELEGRAM_ALLOWED_USER_IDS — from @userinfobot on Telegram
# CLAUDE_PROJECT_DIR — full path to your project
# Start the bot
pm2 start pm2.config.cjs
pm2 save # saves process list so it restores on reboot
One thing:
pm2 startupthrows “Init system not found” on Windows. Ignore that error — usepm2-windows-startup installinstead (run separately, not aspm2 startup). This is a known Windows/PM2 quirk.
The commands you’ll actually use
| Command | What happens |
|---|---|
/newpost My Post Title |
Creates branch → Claude writes the post → approval gate |
/deploy fix nav bug |
Stages all changes → creates branch → approval gate |
/status |
Git status + last 5 commits |
/branches |
Lists all branches waiting for your approval |
/session |
Shows your active Claude session UUID |
/reset |
Clears the stored session — next message starts fresh |
/summarize |
Reads session transcript → generates blog draft / X post / summary |
| Any text | Runs as claude --resume <id> -p "<text>" — full project context |
One thing that caught me
The 800ms wait before reading sessions.log is a timing workaround. The SessionStart hook writes to the log file slightly after claude -p starts (not after it finishes). Without the wait, newSessionId() finds nothing and the session doesn’t get stored.
A cleaner solution would be to poll until the log file changes. The 800ms delay has been reliable in practice — but if you’re on a very slow machine or have heavy SessionStart hooks, increase it to 1500ms.
Moving to a cloud server later is straightforward. Copy telegram-bot/ to a VPS, clone the project there, update CLAUDE_PROJECT_DIR in .env, and start PM2. Zero code changes. The bot is completely portable.
If you’re building something similar and hit an edge case — particularly around session detection or Windows path handling — I’m happy to help work through it. Reach out at bilalmeccai.com/#contact or bilalmeccai@gmail.com.
Frequently Asked Questions
Does the bot work when my computer is off?
CLAUDE_PROJECT_DIR in .env needs changing.How does the bot keep conversation context across messages?
claude --resume <uuid>. /reset to start fresh.How do I stop other people from using my bot?
TELEGRAM_ALLOWED_USER_IDS in .env to your Telegram user ID. Get it from @userinfobot. Any other user ID is silently dropped.Can I approve merges from my phone?
git merge → git push → Vercel deploys.What does the bot do if Claude changes nothing?
git status --short after Claude runs. If there are no changes, it deletes the feature branch silently and returns the Claude response directly — no approval prompt needed.Let's talk about yours.