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.
I think what makes this pattern powerful is not the automation itself, but the fact that agents can do judgment-heavy work in the background. Originally people used schedulers for simple scripts — cron for backups, launchd for syncs. Now we use them for tasks that require reading code, forming an opinion, and writing a report, because that's what AI agents actually do well.
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)
Keep reading
- OpenAI Codex CLI setup — comparison with Claude Code and Cursor
OpenAI's official coding-agent CLI. Install, auth, autonomy modes, and how it compares to Claude Code — all in 30 minutes.
- GitHub Copilot setup — splitting work between Copilot, Claude Code, and Cursor
30-minute setup for Copilot in VS Code and JetBrains. Plus a decision table for which jobs go to Copilot vs Claude Code/Cursor.
- Local LLMs with Ollama — Open WebUI · Continue.dev integration
Run Llama / Qwen / DeepSeek locally on Mac and Windows. Cost, privacy, offline — an alternative to cloud LLMs.