Run Claude Code as an unattended scheduled worker — cron on Linux, Task Scheduler on Windows — with the guardrails that make that sane: a git-based cross-machine lock, failure alerts to your phone, and a prompt template with hard boundaries.
Extracted from a setup where headless Claude agents have been doing real daily work for months (inbox triage, KPI reporting, content publishing) across multiple machines with no human watching.
cron / schtasks
└─ runner.sh <task> <project-dir> <prompt-file>
├─ claim-task.sh ← git-push-as-distributed-lock (optional, multi-machine)
├─ claude -p "Read <prompt-file> and follow it exactly, then stop."
├─ append everything to ~/<task>-auto.log
└─ on failure: roll the lock back + push a ntfy.sh alert to your phone
The prompt file is the task definition. The runner never changes; you write one markdown file per task.
| File | Role |
|---|---|
runner.sh |
Lock → run → log → alert. The whole lifecycle in one file. |
claim-task.sh |
Distributed daily lock using git push atomicity. No server, no Redis — any shared private repo works. |
runner.cmd |
Windows Task Scheduler equivalent (four set lines to edit). |
prompts/example-task.md |
Prompt template: Context / Task / Hard boundaries / Reporting. |
register-task.example.md |
One-line crontab and schtasks registration examples. |
Claim first, run second. Two instances once ran the same task simultaneously — the window between "read the state file" and "write it back" is a race. The fix: writing the lock is the race, and git push makes it atomic (only one non-fast-forward push wins). Better to occasionally skip a run than to ever run twice.
A failed run must release the lock. Claiming the day and then crashing means tomorrow's check says "already ran" — a silent one-cycle skip. runner.sh rolls the state file back to its previous value on any non-zero exit.
Never reset --hard a shared repo. Other agents may have uncommitted work in the same clone. The lock rollback touches exactly one path.
Silence is a failure mode. An expired token or an exhausted usage quota makes headless runs exit "successfully" having done nothing. Alert on failure (NTFY_TOPIC), and consider a heartbeat check on the output (did the expected commit/file actually appear?) rather than trusting exit codes.
Prompts need hard boundaries, not vibes. Every unattended prompt here carries an explicit stop-list — credentials, payments, irreversible actions, anything outside the repo — with the instruction to log-and-exit rather than improvise. See the example.
Windows traps. Batch files must stay pure ASCII (cmd.exe parses them in the ANSI codepage — a CJK comment can get executed; there's a hook for that). And anything spawning subprocesses on a CJK-locale Windows needs explicit UTF-8 (encoding="utf-8" in Python's subprocess), or reading gh/git output will crash.
Headless runs can't answer permission prompts, so the flag is unavoidable — which means your safety story moves to the layers around it: the prompt's hard boundaries, PreToolUse guardrail hooks, and running tasks in repos where the blast radius is acceptable. Don't point an unattended agent at anything you couldn't restore from git.
- claude-code-safety-hooks — the guardrail hooks referenced above
- claude-code-session-sharing — hand off in-progress sessions across machines
- claude-code-todo-digest — every repo's TODO backlog, one nightly email
- claude-code-windows-screenshot-paste — fix screenshot paste on Windows
MIT