A blueprint, not a turn-key product. It captures the architecture and the hard-won design decisions behind running Claude Code headless on a cheap VPS, driven two ways:
- Interactive — you text a Telegram bot, it runs
claudeon the server and texts the answer back. - Autonomous — a nightly cron hands Claude a research brief and delivers a finished report to Telegram by morning.
Why a blueprint and not code-you-clone? The valuable part here isn't a few hundred lines of Python — it's the failure modes you only discover in production (orphaned chromium processes, OAuth token timing, sync that silently dies). This repo hands you the architecture and the gotchas. You hand it to your own Claude and have it build the implementation against your stack, your secrets, your VPS. The reference snippets in
reference/are annotated skeletons with placeholders, not a working deploy.
┌──────────────────────────────┐
Telegram ──text──▶ │ Bot process (systemd) │
│ aiogram long-polling │
└──────────────┬───────────────┘
│ spawn, capture stdout
▼
┌──────────────────────────────┐
cron (nightly) ──▶ │ claude -p (headless CLI) │
│ --dangerously-skip-perms │
│ runs in workspace/ │
└──────────────┬───────────────┘
│ result
▼
Telegram ◀──text / document──
workspace/ ◀── git pull (cron, every 30 min) ── GitHub (your repo)
Two entry points (Telegram message, cron tick), one engine (claude -p headless), one shared workspace that stays fresh via git pull.
A long-running bot (systemd service, not Docker — you want a stable long-poll, not a restart-happy container) receives a message, runs claude -p "<message>" in the workspace directory, and returns stdout.
The non-obvious decisions:
- Run Claude in its own process group (
start_new_session=True) and on timeout kill the whole group (killpg), not just theclaudeprocess. Claude spawnsbash → node → chromiumfor things like screenshots; a plainproc.kill()leaves those grandchildren orphaned (re-parented to PID 1), still holding resources, and the bot reads as "busy" until the next timeout. This single bug will waste a day if you don't know it up front. Seereference/claude-runner.py. - Hard wall-clock timeout on every run. Headless agents can wedge. A 300s cap with a clean kill-tree is the floor.
- Allowlist by Telegram user ID in env — never open the bot to the world. It runs a shell on your server.
- Inject standing instructions via
--append-system-promptinstead of trusting the model to remember conventions across stateless-pcalls (see the screenshot rule below).
If your agent ever renders HTML→PNG on the VPS, do not let it write ad-hoc puppeteer with waitUntil: 'networkidle0'. On a headless server, any HTML that references an external font or CDN that never loads makes networkidle0 hang forever. Three layers fix it:
- A single screenshot helper (
shot <input> <output>) usingpuppeteer-core+ system Chromium, with sane timeouts (waitUntil: 'load', ~30s nav + ~45s hard cap) — on timeout it screenshots whatever rendered instead of hanging. - Force the agent to use it via
--append-system-prompt("NEVER write ad-hoc puppeteer, callshot"). - A watchdog cron (every few minutes) that SIGKILLs any chromium/render or orphaned claude-wrapper older than N minutes — the backstop for everything the first two miss.
You queue a research brief during the day; cron runs it at night; a finished, cited report lands in Telegram by morning. The interesting part is cost control via model tiering.
The insight: the expensive lever in agentic research isn't which model reasons — it's which model reads the raw context (search results, scraped pages). Raw context is huge; conclusions are small. So split into three phases:
| Phase | Model | Reads | Writes |
|---|---|---|---|
| Gather | cheap (e.g. Sonnet) | runs all search/scrape | raw structured notes |
| Synthesize | expensive (e.g. Opus) | only the notes | analysis, conclusions |
| Write | cheap (e.g. Sonnet) | notes + synthesis | final formatted report |
The expensive model never touches raw scrapes — it reads distilled notes, so it's cheap in absolute terms while still doing the hard reasoning. See reference/night-run.sh.
Other decisions that matter:
- Resume by artifact. Each phase writes a file (
notes.md,synth.md,report.md). If a phase dies, the next cron slot sees the artifact already exists and continues from there — finished phases are never re-run. - Multiple cron slots as retry windows. A primary slot plus a couple of later ones; a failed run is retried in the next slot with a prompt that says continue the partial report, don't restart. Bounded by a max-attempts counter.
- Time the slots to your subscription's token refresh. If you're on a subscription with a rolling quota, schedule the safety-net slot just after the refresh boundary (e.g. a few minutes past, not exactly on it, to avoid colliding with the previous slot's lock).
flockso slots never overlap. One run at a time, period.- Delivery is push-only. The VPS has a read-only deploy key (it pulls the workspace, never pushes). The report is delivered as a Telegram document, not committed back.
The agent works against a workspace/ that mirrors your repo. Sync it with a cron git pull --ff-only every ~30 min, using a read-only deploy key.
Learn from the scar: an earlier rsync-from-laptop approach silently stopped working for five weeks (Network is unreachable inside the scheduler's restricted context — no error surfaced). Pull-based sync on the server is observable and self-contained. Add a staleness alert: if workspace/ hasn't updated in >2h, ping yourself on Telegram (with anti-spam, e.g. at most once per 6h). A sync you don't monitor is a sync that's already broken.
This repo is meant to be read by an agent. A starting prompt:
Read the README and
reference/in this repo. I want to stand up the same architecture on my own VPS. My stack:<provider/OS>. My Telegram bot token and allowed user ID are in<where>. My Claude auth is<API key / OAuth>. Build me: (1) the Telegram→claude -pbridge as a systemd service with process-group kill-tree on timeout, (2) the screenshot helper + watchdog, (3) the 3-phase night-shiftrun.shwith artifact resume and a cron schedule. Use placeholders for anything secret and tell me exactly what to fill in.
Then iterate with it against your real server.
- A VPS (2 vCPU / 4 GB is plenty) with Claude Code CLI installed and authenticated (API key or subscription OAuth token).
- A Telegram bot token (from
@BotFather) and your numeric Telegram user ID. - A GitHub repo for the workspace + a read-only deploy key on the VPS.
flock,cron, and (for screenshots) system Chromium +puppeteer-core.
No tokens, no IPs, no domains, no usernames, no working .env. The reference code is illustrative skeleton with placeholders. Wiring it to real infrastructure is the part you (and your agent) do.
MIT — see LICENSE.