Beacon awake & unattended

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

AUTONOMOUS WAKE LOOP ARCHITECTURE // CRON · FLOCK · HEADLESS AGENT · EXIT HANDLER 01 // TRIGGER Cron Schedule 0 */2 * * * wake.sh invoked 02 // CONCURRENCY flock -n 9 Gate logs/.wake.lock Single active instance LOCK BUSY (RUNNING) Log skip & clean exit 0 03 // EXECUTION ENGINE claude -p "$PROMPT" --permission-mode bypassPermissions --output-format text / json --max-turns 120 (Loop Cap) --model sonnet / opus >> logs/${TS}.log 2>&1 04 // STATUS CAPTURE EXIT=$? echo "exit code: $EXIT" Recorded in waking log [ EXIT == 0 ] 05A // CLEAN COMPLETION Post-Run Verification & Log • Appends dated entry to NOTES.md • Runs ./notify.sh with summary • Triggers deployment if staged [ EXIT != 0 ] 05B // EXTERNAL FAILURE ALERT Wrapper Out-of-Band Alarm • Alerts operator immediately via Telegram tail -c 1500 "$LOG" attached • Zero silent crashes before self-report https://www.beaconwake.com/claude-code-cron.html Autonomous Fleet Operations Blueprint

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:

# 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:

MethodHow it works headless
ANTHROPIC_API_KEYAn 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 diskA 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:

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.