Personal dev-machine bootstrap: clone, make install, get the same
Claude-Code-over-Telegram setup on every machine. Currently ships:
| Binary | What it does |
|---|---|
cl |
Launches Claude Code in a tmux session (cl) with the Telegram channel attached. Workdir defaults to ~/workspace/clawy if it exists, else $HOME. Override with $CL_WORKDIR. On a fresh launch (no existing session) it first stops every running docker container to reclaim memory — restart your project's stack when you next need it. Re-attaching to a live session leaves docker untouched. |
cl-clear |
Restarts the cl session's claude process via tmux respawn-window -k — the session itself never dies, so there is no kill/new server-exit race. Retries with backoff, then posts the real outcome (success or failure) to the chat_id given as the first arg. Used by the !clear hook (see below), but safe to run manually. |
cl-watchdog |
Health check: relaunches the cl tmux session if it has died. Run every 60s by the cl-watchdog systemd user timer (see Watchdog). Silent when healthy; logs state changes to log/watchdog.log. |
transcribe FILE |
Sends an audio file to OpenAI Whisper and prints the transcript to stdout. Needs your own OpenAI API key (see Voice transcription). |
On a fresh machine:
git clone https://github.com/hendricius/dev-vm.git ~/dev-vm
cd ~/dev-vm && make installmake install is the one command that fully sets up the machine. Each step
is idempotent, so re-running it is safe and fast:
- install-deps — system packages (
curl,tmux,python3,jq,git,ca-certificates) via apt/dnf/yum/pacman/apk/brew, plus Bun via its official installer (the Telegram MCP plugin runs on Bun). - link-bins — symlinks
~/.local/bin/{cl,cl-clear,cl-watchdog,transcribe}into the checkout. - install-claude-skills — symlinks
claude/skills/to~/.claude/skills/, exposing the shipped skills as user-invocable slash commands:/transcribe <path>— runs~/.local/bin/transcribeon an audio file and reports the transcript. Also reinforces the auto-trigger on inbound voice messages via its description./tmux— helpers for driving tmux sessions.
- install-telegram-plugin — if
claudeis on PATH, runsclaude plugin install -s user telegram@claude-plugins-official(idempotent; no-ops if already installed). Thenbun installinside the plugin dir to prewarm node_modules so the first launch doesn't stall on dep resolution. - install-telegram-clear-hook — injects a
UserPromptSubmithook into~/.claude/settings.jsonso that an exact!clearmessage from Telegram triggerscl-clear, which restarts theclaudeprocess in theclsession and posts the outcome back to the chat. The hook firescl-clearundersetsidso it can't be killed mid-restart. Idempotent; preserves any other hooks in the file. - install-telegram-goal/loop/effort/model-hooks — more
UserPromptSubmithooks that bridge Telegram messages to real harness slash commands:!goal→/goal,!loop→/loop,!effort→/effort,!model→/model(see Remote/goaland the sections after it). Idempotent; preserve any other hooks. - install-skill-sync-hook —
Stophook that auto-commits and pushes any change underclaude/skills/(see Auto-sync skills). - install-telegram-session-mark + askuser-guard hooks — tag
Telegram-driven sessions and block
AskUserQuestion/ExitPlanModein them, because Telegram has no UI to answer those interactive pickers and the session would stall. - install-cl-watchdog — installs the
cl-watchdogsystemd user timer (see Watchdog). Best-effort: skips cleanly if the systemd user instance isn't reachable, somake installnever fails on minimal images.
Make sure ~/.local/bin is on your PATH.
| Step | Why manual | How |
|---|---|---|
| Install Claude Code | Multiple install paths (npm, native, brew). Pick one. | https://claude.com/claude-code |
| Telegram bot token | One bot per machine, you create it via BotFather. | Inside claude: /telegram:configure <bot-token> |
| First-time pairing | Per-machine security boundary. | DM the bot → it returns a code → inside claude: /telegram:access pair <code> |
| OpenAI API key (optional) | Only needed for transcribe; billed to your own OpenAI account. |
export OPENAI_API_KEY=… or write the key to ~/.config/openai/api-key (chmod 600) |
After make install, run make doctor — it tells you exactly which of the
manual steps are still pending.
cl and cl-watchdog resolve the launch directory via, in order:
$CL_WORKDIR(explicit full path) — the per-machine knob.~/workspace/$CL_WORKSPACE(default subdirclawy, if it exists).~/workspace.$HOME.
Because the right folder differs per machine, set CL_WORKDIR via env —
not by patching bin/cl, which would conflict on every git pull. Two
places, because systemd user services don't inherit your shell env:
Interactive cl — add to ~/.zshrc (or ~/.bashrc):
export CL_WORKDIR="$HOME/workspace/myproject"cl-watchdog — systemd drop-in, since the timer runs without your shell:
mkdir -p ~/.config/systemd/user/cl-watchdog.service.d
cat > ~/.config/systemd/user/cl-watchdog.service.d/override.conf <<'EOF'
[Service]
Environment=CL_WORKDIR=%h/workspace/myproject
EOF
systemctl --user daemon-reloadVerify with systemctl --user show cl-watchdog.service -p Environment. Per-machine
config lives in ~/.config/ and ~/.zshrc, so dev-vm stays pristine and
make update never conflicts.
make update # = git pull --ff-only && make installBecause make install uses symlinks, any change you commit and pull is
picked up immediately — no copy step needed. The update target also re-runs
install in case binaries were added.
The cl tmux session is the lifeline for the Telegram channel — if it dies,
you can't reach Claude until you SSH in and run cl by hand. Two layers keep
it alive:
- Atomic restart.
cl-clear(the!clearhandler) usestmux respawn-window -k, which restarts theclaudeprocess inside the existing window. The session never dies, so there's no kill-session / new-session race that can leave the server down. It also retries with backoff and reports the real outcome — a restart can't fail silently. cl-watchdogtimer. A systemd user timer runscl-watchdogevery 60s. If theclsession is ever missing — a crash, an OOM kill, a restart that somehow still failed — it's relaunched within a minute, no SSH-in required. Healthy checks are silent; state changes go tolog/watchdog.log. The service setsKillMode=process: without it, systemd reaps the tmux server the watchdog just spawned as soon as the oneshot exits, and the log claims "relaunched OK" forever whiletmux lsstays empty.- Usage-limit auto-resume. When the Claude plan quota runs out, the TUI
parks on an interactive prompt that nobody headless can answer — Telegram
goes dead until a human SSHes in. The watchdog detects that screen via
capture-pane(same limit-looking pane two ticks in a row), parses the reset time, notifies the user on Telegram, and once the window passes restarts claude with--continueplus a nudge prompt so it picks the conversation back up on its own. State lives inlog/limit-state, pane snapshots (for refining the detection patterns) inlog/limit-pane.log. Opt out per-box withtouch log/limit-watch.off.
make install-cl-watchdog # install / re-enable the timer
systemctl --user status cl-watchdog.timer # inspect
systemctl --user list-timers cl-watchdog.timer # next/last run
journalctl --user -u cl-watchdog.service # run history
~/.local/bin/cl-watchdog # run the check right now
tail -f ~/dev-vm/log/watchdog.log # state-change logThe timer needs the systemd user instance plus linger so it keeps firing when no SSH session is attached.
install-cl-watchdogenables linger automatically when it can; if it couldn't (no polkit rule), run it once as root:sudo loginctl enable-linger $USER.make doctorreports both the timer and linger state.
Claude Code 2.1.139+ ships a built-in /goal <condition> command: you set a
completion condition and the harness keeps working, turn after turn, until a
fast model judges the condition met. It's a harness-level command, though —
typing /goal into Telegram just sends literal text, because channel messages
arrive as data, not as commands.
The !goal hook bridges that gap. Send from Telegram:
!goal <condition> → runs /goal <condition> (set a goal)
!goal → runs /goal (show goal status)
!goal clear → runs /goal clear (clear the active goal)
scripts/telegram-goal-hook.sh is a UserPromptSubmit hook. It can't execute
a slash command — a hook can only block a prompt or add context — so instead it
blocks the !goal prompt and uses tmux send-keys to type the real /goal
command into the cl session, exactly as if you'd typed it at the keyboard.
The injection runs detached under setsid after a short delay so the blocked
prompt has unwound first, and posts a 🎯 Injected … confirmation back to the
chat (the harness's own /goal acknowledgement is terminal-only).
Every invocation is logged to ~/.claude/channels/telegram/goal-hook.log. To
test the parse/extract logic without driving a real session into goal mode, run
the hook with CL_GOAL_HOOK_DRYRUN=1 — it logs and prints what it would
inject, then exits without touching tmux.
Hooks load at session startup. After
make installadds the!goalhook, the runningclsession won't have it yet — send!clearonce to restart and pick it up. New sessions have it from the start.
Same bridge, for the harness /loop command. Send from Telegram:
!loop <interval> <prompt|/cmd> → runs /loop <interval> <prompt|/cmd>
!loop <prompt|/cmd> → runs /loop <prompt|/cmd> (self-paced)
!loop → runs /loop
scripts/telegram-loop-hook.sh is the matching UserPromptSubmit hook — it
blocks the !loop prompt and tmux send-keys the real /loop into the cl
session, posting a 🔁 Injected … confirmation. Logs to
~/.claude/channels/telegram/loop-hook.log; dry-run with CL_LOOP_HOOK_DRYRUN=1.
Same bridge, for the harness /effort command. Send from Telegram:
!effort <level> → runs /effort <level> (level: low|medium|high|xhigh|max|ultracode|auto)
!effort → usage hint (no inject — a bare /effort opens the slider)
!effort bogus → usage hint (unknown level)
A level is required: a bare /effort opens the interactive effort slider
in the TUI, which waits for arrow-key input and would hang this headless cl
session. So the hook only injects for a valid level, and otherwise blocks with
a ⚙️ Usage … hint sent back to Telegram.
scripts/telegram-effort-hook.sh is the matching UserPromptSubmit hook — it
blocks the !effort prompt and tmux send-keys the real /effort into the
cl session, posting a ⚙️ Injected … confirmation. Logs to
~/.claude/channels/telegram/effort-hook.log; dry-run with
CL_EFFORT_HOOK_DRYRUN=1.
Same bridge, for the harness /model command. Send from Telegram:
!model <name> → runs /model <name> (name: default|best|fable|sonnet|opus|haiku|opusplan|opus[1m]|sonnet[1m], or a full claude-… id)
!model → usage hint (no inject — a bare /model opens the picker)
!model bogus → usage hint (unknown model)
A name is required: a bare /model opens the interactive model picker in
the TUI, which waits for arrow-key input and would hang this headless cl
session. So the hook only injects for a valid alias or a full claude-… id, and
otherwise blocks with a 🧠 Usage … hint sent back to Telegram. /model <name>
switches the model immediately and saves it as the default in
~/.claude/settings.json, so the choice survives the next cl relaunch.
scripts/telegram-model-hook.sh is the matching UserPromptSubmit hook — it
blocks the !model prompt and tmux send-keys the real /model into the cl
session, posting a 🧠 Injected … confirmation. Logs to
~/.claude/channels/telegram/model-hook.log; dry-run with
CL_MODEL_HOOK_DRYRUN=1.
Same bridge, for the harness /usage command. Send from Telegram:
!usage → runs /usage and relays the rendered panel back to the chat
/usage differs from the other bridged commands: it opens an interactive TUI
panel that only ever draws to the terminal, so merely injecting it would show
you nothing on your phone. So scripts/telegram-usage-hook.sh does more — it
blocks the !usage prompt, tmux send-keys the real /usage into the cl
session, waits for the panel to fetch + render, tmux capture-panes the visible
pane, posts that text back to the originating chat (monospace code block), and
presses Escape to dismiss the panel. That way you get the account-wide usage
numbers in Telegram, which /usage alone can't deliver. Logs to
~/.claude/channels/telegram/usage-hook.log; dry-run with
CL_USAGE_HOOK_DRYRUN=1.
Install with scripts/install-telegram-usage-hook.sh (remove with the matching
uninstall- script). Like the other hooks, a running cl session only picks it
up after a !clear; new sessions have it from the start.
transcribe FILE [LANGUAGE] sends the audio straight to OpenAI's
transcription API and prints the transcript — no middleman service. Telegram
voice notes (.oga), mp3, m4a, wav, webm and flac all work. Paired with the
/transcribe skill, a Claude session on this box can transcribe inbound
Telegram voice messages on its own.
Configuration, all via env (or the key file):
| Knob | Default | Meaning |
|---|---|---|
OPENAI_API_KEY |
— | Your API key. Alternatively write it to ~/.config/openai/api-key (single line, chmod 600). |
OPENAI_TRANSCRIBE_MODEL |
whisper-1 |
Also works with gpt-4o-mini-transcribe / gpt-4o-transcribe. |
OPENAI_TRANSCRIBE_PROMPT |
— | Vocabulary hint for names the model would otherwise mishear (project names, jargon). |
OPENAI_API_BASE |
https://api.openai.com |
Any OpenAI-compatible server works. |
export OPENAI_API_KEY=sk-… # or: ~/.config/openai/api-key
transcribe voice-note.oga # → transcript on stdout
transcribe interview.mp3 de # ISO-639-1 language hint~/.claude/skills is a single symlink to claude/skills/ in this repo, so every
skill is version-controlled and a new skill created under ~/.claude/skills lands
in the repo working tree automatically. But the working tree is not the remote —
a new local skill still needs a git commit + push to reach it (and your
other machines, on their next make update).
scripts/skill-sync-hook.sh closes that gap. It is a Stop hook: when Claude
finishes a turn, it checks git status for changes under claude/skills/ and, if
any, commits them (auto: skill-sync (<names>)), git pull --rebase --autostash,
and git push — all detached so the push never blocks the turn, and scoped to
claude/skills/ only (Makefile / scripts / README stay manual). On turns that
didn't touch a skill it's a cheap no-op.
Install with scripts/install-skill-sync-hook.sh (remove with the matching
uninstall- script; both wired into make install). Logs to
log/skill-sync.log in the repo; dry-run with CL_SKILL_SYNC_DRYRUN=1. Hooks
load at session start, so a running cl session picks it up after a !clear.
make doctorReports whether the symlinks are in place, the telegram plugin + hooks are
installed, the cl-watchdog timer and linger are enabled, and whether
claude/tmux/jq are available.
None. The repo tracks no API keys or tokens. The two credentials the setup can use live outside the repo, per machine:
- Telegram bot token — configured inside claude via
/telegram:configure <bot-token>; lives in~/.claude/channels/telegram/.env. - OpenAI API key (optional, for
transcribe) —$OPENAI_API_KEYor~/.config/openai/api-key(chmod 600).
If you ever need to stage machine-local credentials next to the checkout, put
them under secrets/ — the whole directory is gitignored.
- Drop the script in
bin/, mark executable. - Append it to the
BINS :=line in theMakefile. make installto symlink it locally.- Commit + push.