Running AI Agents in the Background — cron · launchd · Task Scheduler
Scheduling patterns to run AI agents like Claude Code periodically without a human at the keyboard. macOS launchd · Linux cron · Windows Task Scheduler compared.
AI agents like Claude Code aren't only for when you're sitting at the keyboard. Nightly PR review, issue triage, README sync, log summary — value compounds when they run unattended.
This guide compares the standard scheduling tools on three OSes (macOS launchd, Linux cron, Windows Task Scheduler) against the same scenario, and highlights AI-agent-specific pitfalls (API key safety, log capture, failure notifications).
TL;DR
| OS | Tool | Definition location | Logs |
|---|---|---|---|
| macOS | launchd (LaunchAgent) | ~/Library/LaunchAgents/<label>.plist | StandardOutPath/StandardErrorPath |
| Linux | cron | crontab -e or /etc/cron.d/<name> | MTA or redirect >> log.txt 2>&1 |
| Windows | Task Scheduler | GUI or PowerShell Register-ScheduledTask | Event Viewer or file redirect |
Five common pitfalls:
- Empty PATH — commands not found
- API keys stored in plaintext in plist/crontab (DON'T)
- Concurrent runs of the same job racing
- Silent death — no failure notification
- Machine asleep — job skipped entirely
§1–§3 cover OS-specific patterns; §4 the pitfalls.
Prerequisites
- One AI agent to run (e.g., Claude Code, Cursor CLI, custom script)
- API keys managed via env or a secret manager
- One shell script (e.g.,
~/agents/daily-pr-review.sh)
1. macOS — launchd LaunchAgent — 10 min
cron exists on macOS, but Apple recommends launchd. Catch-up runs after sleep / re-login are more reliable too.
1.1 Write the plist
~/Library/LaunchAgents/dev.local.daily-pr-review.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>dev.local.daily-pr-review</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>$HOME/agents/daily-pr-review.sh</string>
</array>
<!-- Environment: PATH may be empty so set it explicitly -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<!-- Do NOT put ANTHROPIC_API_KEY here — see §4.2 -->
</dict>
<!-- Daily at 09:00 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
<!-- Catch-up after sleep -->
<key>RunAtLoad</key><false/>
<key>StartOnMount</key><false/>
<!-- Logs -->
<key>StandardOutPath</key>
<string>/tmp/daily-pr-review.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/daily-pr-review.err.log</string>
</dict>
</plist>1.2 Register + Run
# Register
launchctl load ~/Library/LaunchAgents/dev.local.daily-pr-review.plist
# Run immediately (skip schedule)
launchctl start dev.local.daily-pr-review
# Status
launchctl list | grep daily-pr-review
# Unregister
launchctl unload ~/Library/LaunchAgents/dev.local.daily-pr-review.plist1.3 Catch-up After Sleep
cron skips runs that fall during sleep. launchd will run once at wake (note: StartCalendarInterval only catches up once).
For hourly etc. catch-up:
<key>StartInterval</key>
<integer>3600</integer> <!-- every hour -->→ Runs once after waking, unlike cron.
2. Linux — cron — 5 min
2.1 Edit crontab
crontab -eExample:
# m h dom mon dow command
0 9 * * * /home/user/agents/daily-pr-review.sh >> /home/user/logs/pr-review.log 2>&1| Field | Value | Meaning |
|---|---|---|
0 | 0 | on the hour |
9 | 9 | 9 am |
* * * | every | every day |
Daily 09:00, append stdout/stderr to a log file.
2.2 Set PATH Explicitly
cron's PATH is very bare. Set it in the script or at the top of crontab:
PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/sbin
SHELL=/bin/bash
0 9 * * * $HOME/agents/daily-pr-review.sh >> $HOME/logs/pr-review.log 2>&12.3 System cron (/etc/cron.d/)
For root-level registration on servers:
sudo tee /etc/cron.d/daily-pr-review > /dev/null <<'EOF'
PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash
0 9 * * * appuser /home/appuser/agents/daily-pr-review.sh >> /var/log/agent.log 2>&1
EOFThe appuser field specifies the run user. Important for security.
2.4 systemd Timer (modern alternative)
A cron alternative. Unit files unify restart, failure handling, and logging.
# /etc/systemd/system/pr-review.service
[Unit]
Description=Daily PR review agent
[Service]
Type=oneshot
User=appuser
ExecStart=/home/appuser/agents/daily-pr-review.sh
StandardOutput=journal
StandardError=journal# /etc/systemd/system/pr-review.timer
[Unit]
Description=Run daily PR review at 09:00
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true # run after boot if time passed during downtime
[Install]
WantedBy=timers.targetsudo systemctl enable --now pr-review.timer
journalctl -u pr-review.service # logsPersistent=true is the big win over cron — auto-catch-up after server downtime.
3. Windows — Task Scheduler — 10 min
GUI works, but make it reproducible via PowerShell.
3.1 Register via PowerShell
# Action
$action = New-ScheduledTaskAction `
-Execute "wsl.exe" `
-Argument "-d Ubuntu -e bash -lc '~/agents/daily-pr-review.sh'"
# Trigger: daily 09:00
$trigger = New-ScheduledTaskTrigger -Daily -At 9am
# Settings: run even when user not logged in; wake from sleep
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Hours 1) `
-RestartCount 2 `
-RestartInterval (New-TimeSpan -Minutes 10)
# Register
Register-ScheduledTask `
-TaskName "DailyPRReview" `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Description "Run Claude Code daily PR review in WSL"3.2 Confirm in GUI
Win + R → taskschd.msc → Task Scheduler Library → "DailyPRReview".
3.3 Test Immediately
Start-ScheduledTask -TaskName "DailyPRReview"
Get-ScheduledTaskInfo -TaskName "DailyPRReview" | Select LastRunTime, LastTaskResultLastTaskResult: 0 means success.
3.4 Call into WSL
As above, wsl.exe -d Ubuntu -e bash -lc '...'. Env vars and API keys live in WSL's ~/.bashrc or a separate env file.
3.5 Log Capture
Task Scheduler's own log is sparse. Redirect inside the script:
# ~/agents/daily-pr-review.sh
#!/usr/bin/env bash
set -euo pipefail
LOG="$HOME/logs/pr-review-$(date +%Y%m%d).log"
mkdir -p "$(dirname "$LOG")"
exec >> "$LOG" 2>&1
echo "[$(date)] starting..."
# Run the agent
claude-code --resume --task pr-review --quiet
echo "[$(date)] done."4. AI-Agent-Specific Pitfalls
4.1 Empty PATH — #1 cause of "doesn't run"
Interactive shell PATH ≠ launchd/cron PATH. State it at the top of your script:
#!/usr/bin/env bash
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"Or via the plist/crontab EnvironmentVariables / PATH= header.
Verify:
launchctl getenv PATH # macOS
sudo -u appuser env | grep PATH # Linux cron user4.2 Never Put API Keys in plist/crontab
ANTHROPIC_API_KEY=sk-ant-... inside a plist EnvironmentVariables block:
- Exposed by
ls -la - Stored in Time Machine / iCloud Backup as plaintext
- Other users on the same machine read it via
cat ~/Library/LaunchAgents/*.plist
Instead:
- macOS: Keychain + script reads via
security find-generic-password -s anthropic-api -w - Linux: separate
~/.config/agent/envfile withchmod 600+sourcein script - Windows:
Get-Credentialor Credential Manager
Script (macOS):
#!/usr/bin/env bash
set -euo pipefail
export ANTHROPIC_API_KEY="$(security find-generic-password -s anthropic-api -w 2>/dev/null)"
[ -z "$ANTHROPIC_API_KEY" ] && { echo "missing API key in Keychain"; exit 1; }
# ... run agentRegister the key once:
security add-generic-password -s anthropic-api -a "$USER" -w 'sk-ant-...'4.3 Prevent Concurrent Runs — flock / single instance
Double runs = 2x API cost + race. Lock with flock:
#!/usr/bin/env bash
exec 200>/tmp/daily-pr-review.lock
flock -n 200 || { echo "already running"; exit 0; }
# ... run agentflock -n = non-blocking; exits immediately if locked.
4.4 Don't Die Silently — Failure Notifications
By default, stderr ends in a log file and that's it. Use trap to notify:
#!/usr/bin/env bash
set -euo pipefail
notify_failure() {
local code=$?
if [ $code -ne 0 ]; then
# macOS — terminal-notifier or osascript
osascript -e "display notification \"PR review failed (exit $code)\" with title \"Agent\""
# Or Slack webhook / email / Sentry
# curl -X POST -H 'Content-Type: application/json' \
# -d "{\"text\":\"Agent failed: exit $code\"}" "$SLACK_WEBHOOK_URL"
fi
}
trap notify_failure EXIT
# Run agent4.5 Catch-Up After Sleep / Boot
- macOS launchd:
StartCalendarIntervalcatches up once after wake from sleep - Linux systemd timer: use
Persistent=true - Windows: Task Scheduler's "Run task as soon as possible after a scheduled start is missed" ON
Only cron lacks sleep catch-up — that's why nobody uses cron on laptops.
5. Real Scenario — Claude Code Daily PR Review
~/agents/daily-pr-review.sh:
#!/usr/bin/env bash
set -euo pipefail
# 1. PATH + secrets
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
export ANTHROPIC_API_KEY="$(security find-generic-password -s anthropic-api -w)"
export GITHUB_TOKEN="$(security find-generic-password -s github-token -w)"
# 2. Lock
exec 200>/tmp/daily-pr-review.lock
flock -n 200 || exit 0
# 3. Log
LOG="$HOME/logs/pr-review-$(date +%Y%m%d).log"
mkdir -p "$(dirname "$LOG")"
exec >> "$LOG" 2>&1
echo "[$(date)] starting daily PR review..."
# 4. Work
cd "$HOME/code/my-repo"
git fetch --quiet origin
gh pr list --state open --json number,title,author --jq '.[] | select(.author.login != "me")' | \
while read -r pr; do
num=$(echo "$pr" | jq -r .number)
echo "Reviewing PR #$num..."
claude-code --task "Review PR #$num — focus on security, performance, missing tests" --quiet
done
echo "[$(date)] done."Register as a LaunchAgent → runs daily at 09:00 automatically.
6. Troubleshooting
launchd "Service exited with abnormal code: 127"
Empty PATH or non-executable script. chmod +x ~/agents/*.sh + set PATH in the plist.
cron never runs and there's no log
- crontab syntax error: check via
crontab -l - Without MTA (mailutils), stderr is lost → explicit
2>&1 >> log - Inspect run attempts with
journalctl -u cron(or cronie)
Task Scheduler "Last Task Result: 0x1"
Script exited 1. Debug by running it directly inside WSL. Inspect $LASTEXITCODE.
claude-code Command Not Found
Script PATH missing ~/.npm-global/bin or your npm global bin. Add which claude-code's dir to PATH.
Next Steps
- Claude Code setup — prerequisite
- Claude Code hooks — automation event patterns
- Multi-agent workflow — multi-agent collaboration
References
Changelog
- 2026-05-12 — Initial draft (devAlice M2 seed expansion)