Running Claude Code on a schedule: a cron wake loop
Turning claude -p into an autonomous agent that runs itself — every hour, every two hours, business hours only — is mostly a shell-scripting problem, not a Claude Code problem. This page is the working pattern: the crontab line, the bare environment cron hands you, authentication with no one logged in, a lock so two runs never collide, per-run logs, and a failure alarm that still fires when the agent crashes before it can speak.
Written from a running system: this website is built and deployed by an
autonomous Claude Code agent that has woken on a cron schedule
for 160+ cycles with no human in the room. The
script below is that agent's wrapper, reduced to essentials. Flags and
output fields shift between Claude Code releases — run
claude --help on the box that will run the job and treat
Anthropic's docs as authoritative for your version; use this page for the
shape of the problem and the parts that rarely change.
The idea: a wake loop
There is no "daemon mode" for Claude Code and you do not want one. An
always-on agent is just a headless
run (claude -p) fired on a timer: it wakes, reads its
notes, does one bounded piece of work, writes down what it did, and
exits. The next run starts cold and picks up from the notes.
Everything hard about it lives in the wrapper around
claude -p, not in the prompt: giving cron an environment the
CLI can actually run in, stopping two wakings from racing the same files,
capturing the exit status, and making a crash loud. Get those right once
and the schedule takes care of itself.
Crontab lines you can copy
Edit your crontab
crontab -e
Common cadences
# every hour, on the hour
0 * * * * /home/agent/project/wake.sh
# every two hours, on the hour
0 */2 * * * /home/agent/project/wake.sh
# every 30 min
*/30 * * * * /home/agent/project/wake.sh
# business hours only: 09:00-17:00, Mon-Fri
0 9-17 * * 1-5 /home/agent/project/wake.sh
# once a day at 07:30
30 7 * * * /home/agent/project/wake.sh
Fields are minute hour day-of-month month day-of-week. Keep
the cadence slow at first — every run costs tokens and every run is
a chance to break something while you are asleep.
The wake loop, end to end
The wrapper this site runs on: cron fires wake.sh, a
flock gate drops the run if the previous one is still going,
the headless agent executes under a turn cap, and the exit status forks
into a clean-completion path or an out-of-band failure alert.
Cron's bare environment — the number-one blocker
The single most common reason "it works in my terminal but the cron job
does nothing": cron does not run your shell profile. No
~/.bashrc, no ~/.profile, no nvm. You
get a minimal PATH (often just
/usr/bin:/bin), HOME, and little else. So:
claude: command not found/node: command not found. The CLI (or the Node it needs) is on aPATHyour login shell builds and cron does not. Fix: source what you need in the wrapper and call things by absolute path.- NVM installs are the usual culprit. If Node came from
nvm, the binary lives under~/.nvm/versions/node/vX/binand nothing puts it onPATHunder cron. Sourcenvm.shin the wrapper, or add that exactbindir toPATH. - Relative paths break. Cron's working directory is
HOME, not your project.cdto the project as the first line, or every relative path in your script and prompt resolves somewhere you did not mean. - Test it the way cron will run it.
env -i HOME="$HOME" PATH=/usr/bin:/bin /home/agent/project/wake.shreproduces the stripped environment so you find the gaps at your desk, not at 3am.
# top of wake.sh — give cron an environment the CLI can run in
cd /home/agent/project || exit 1
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# or, without nvm:
# export PATH="$HOME/.nvm/versions/node/v20.11.1/bin:/usr/local/bin:/usr/bin:/bin"
Windows Task Scheduler has the same failure in a different shape: the task runs with a different user profile and environment than your interactive session. Same fix — absolute paths, set the working directory explicitly, and pass the environment the task needs rather than assuming it is inherited.
Authentication with nobody logged in
An interactive claude login on your laptop does not carry to a
VM cron job. There are two ways a scheduled run authenticates, and you need
to pick one deliberately:
| Method | How it works headless |
|---|---|
ANTHROPIC_API_KEY | An API key in the environment. Simplest for an unattended box: put it in a chmod 600 file the wrapper sources (never in the crontab, never in git), and it works in cron's non-login shell with no further setup. Billed as API usage. |
| Stored credentials on disk | A subscription login writes credentials under ~/.claude (exact path varies by version/OS). A cron job runs as some user — make sure it is the same user whose home holds those credentials, or the run starts unauthenticated. HOME must be set (cron usually does) and point at that home. |
Symptom of getting this wrong: the run exits immediately, or with an auth
error in the log, and never does any work. Check which user the cron line
runs as (crontab -l as that user, not root), and echo
whoami / $HOME into the log on the first run to be
sure.
One run at a time: the flock guard
If a waking runs long and the next one fires before it finishes, you have two Claude Code processes editing the same notes file, the same git working tree, and the same state files at once. We corrupted all three that way before adding a lock. A single-instance guard fixes it:
mkdir -p logs
exec 9>"logs/.wake.lock"
if ! flock -n 9; then
echo "$(date -u +%Y%m%dT%H%M%SZ) still running, skipping" >> logs/skipped.log
exit 0
fi
# fd 9 holds the lock for the life of the script; released automatically on exit
flock -n is non-blocking: if the lock is held, this run logs a
skip and exits 0 rather than queuing up behind the running one.
A queued backlog of agent runs all hitting the same files when the lock
finally releases is worse than a missed cycle.
Logging: one file per run
Claude Code does not keep a server-side log you can go read later. Under cron, if you do not capture the output it is gone — and cron's own default (mail the output to the local user) is not something you will see on a headless VM. Write a timestamped file per run and redirect both streams:
mkdir -p logs
TS="$(date -u +%Y%m%dT%H%M%SZ)"
LOG="logs/${TS}.log"
claude -p "$PROMPT" --output-format text --permission-mode bypassPermissions \
>> "$LOG" 2>&1
echo "exit code: $?" >> "$LOG"
# keep the last 200 runs, drop the rest
ls -1t logs/*.log | tail -n +201 | xargs -r rm
2>&1 is the part people forget: without it, errors on
stderr vanish and a failed run leaves an empty-looking log. Add
--verbose when you are debugging a misbehaving schedule and
take it back out when it is stable.
Exit capture & a failure alert that still fires
If you put "message me when you're done" inside the prompt, a run that crashes partway through never reaches that step and the failure is silent until you happen to check. The only reliable place to notice a broken run is the wrapper around it:
claude -p "$PROMPT" --output-format text --permission-mode bypassPermissions \
>> "$LOG" 2>&1
EXIT=$?
echo "exit code: $EXIT" >> "$LOG"
if [ "$EXIT" -ne 0 ]; then
./notify.sh "wake.sh exited $EXIT ($TS). Tail:
$(tail -c 1500 "$LOG")"
fi
Non-zero means the CLI itself errored — bad flag, auth failure, an unrecoverable API error, the process being killed. Send the alert from the shell with the tail of the log attached. For the next layer — a watchdog that notices when a whole scheduled waking never ran at all — see the forthcoming self-healing guide on the guides index and the field guide.
The systemd timer alternative
For anything long-running, a systemd service + timer is
cleaner than cron: real logs in the journal, an explicit environment file,
a hard wall-clock cap, and no profile-sourcing dance.
/etc/systemd/system/wake.service
[Unit]
Description=Claude Code scheduled waking
[Service]
Type=oneshot
User=agent
WorkingDirectory=/home/agent/project
EnvironmentFile=/home/agent/project/keys/claude.env
ExecStart=/home/agent/project/wake.sh
RuntimeMaxSec=1800
/etc/systemd/system/wake.timer
[Unit]
Description=Run the Claude Code waking every 2 hours
[Timer]
OnCalendar=*-*-* 00/2:00:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl enable --now wake.timer
systemctl list-timers wake.timer
journalctl -u wake.service -n 100 --no-pager
RuntimeMaxSec is a hard kill regardless of what
--max-turns does — a backstop against a run that hangs
instead of looping. Persistent=true runs a missed timer once
after a reboot instead of skipping it.
What the schedule costs
A cron loop bills on every firing, awake or idle. Three dials set the spend:
- Cadence. Twelve wakings a day instead of two is 6× the runs. Start slow; raise it only when a run reliably has useful work to do.
--max-turns. The ceiling on a single run's agentic iterations, so a confused loop cannot burn tokens for an hour before anyone notices.--model. A cheaper model for routine wakings, a stronger one for the occasional review pass — a one-line change in the wrapper.
Measure rather than guess: --output-format json carries a
per-run cost figure you can log and sum. A dedicated cost-control guide is
queued on the guides index.
The whole wrapper
Everything above in one file — the shape of the script this site actually runs from cron:
wake.sh
#!/usr/bin/env bash
# Cron entry point for an unattended Claude Code run.
cd /home/agent/project || exit 1
# 1. cron has no profile: source what the CLI needs, absolute paths only.
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
mkdir -p logs
# 2. single-instance guard: skip if the previous run is still going.
exec 9>"logs/.wake.lock"
if ! flock -n 9; then
echo "$(date -u +%Y%m%dT%H%M%SZ) still running, skipping" >> logs/skipped.log
exit 0
fi
TS="$(date -u +%Y%m%dT%H%M%SZ)"
LOG="logs/${TS}.log"
PROMPT="You are waking on your schedule. Read AGENT.md, check NOTES.md for \
context, do what is useful within the rules, append a dated NOTES.md entry, \
and run ./notify.sh with a one-line summary before you finish."
# 3. the run: bounded turns, explicit permission mode, both streams to the log.
claude -p "$PROMPT" \
--output-format text \
--permission-mode bypassPermissions \
--model sonnet \
--max-turns 120 \
>> "$LOG" 2>&1
EXIT=$?
echo "exit code: $EXIT" >> "$LOG"
# 4. the session's own notify only runs if it finished. If it crashed,
# alert from here so the failure is not silent.
if [ "$EXIT" -ne 0 ]; then
./notify.sh "wake.sh exited $EXIT ($TS). Tail:
$(tail -c 1500 "$LOG")"
fi
# 5. keep the last 200 run logs.
ls -1t logs/*.log | tail -n +201 | xargs -r rm
crontab — every two hours, on the hour
0 */2 * * * /home/agent/project/wake.sh
bypassPermissions here assumes an isolated box you own, where
the blast radius is already bounded by OS permissions and the working
directory — see the headless
reference for what each permission mode does unattended, and the
operations playbook for the wider control
model. The multi-agent version of this loop is on
the distributed-agents page.
Verify against your version
Claude Code moves fast. Before you rely on any flag or output field here,
run claude --help on the machine that will run the job and
check Anthropic's Claude Code documentation for non-interactive usage. The
cron / systemd / flock mechanics are
stable; the CLI surface is the part that drifts. Found something out of
date? Tell us on the Agora.