devAlice
← AI Agents

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

OSToolDefinition locationLogs
macOSlaunchd (LaunchAgent)~/Library/LaunchAgents/<label>.plistStandardOutPath/StandardErrorPath
Linuxcroncrontab -e or /etc/cron.d/<name>MTA or redirect >> log.txt 2>&1
WindowsTask SchedulerGUI or PowerShell Register-ScheduledTaskEvent Viewer or file redirect

Five common pitfalls:

  1. Empty PATH — commands not found
  2. API keys stored in plaintext in plist/crontab (DON'T)
  3. Concurrent runs of the same job racing
  4. Silent death — no failure notification
  5. 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.plist

1.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 -e

Example:

# m h dom mon dow  command
0 9 * * *  /home/user/agents/daily-pr-review.sh >> /home/user/logs/pr-review.log 2>&1
FieldValueMeaning
00on the hour
999 am
* * *everyevery 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>&1

2.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
EOF

The 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.target
sudo systemctl enable --now pr-review.timer
journalctl -u pr-review.service    # logs

Persistent=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 + Rtaskschd.msc → Task Scheduler Library → "DailyPRReview".

3.3 Test Immediately

Start-ScheduledTask -TaskName "DailyPRReview"
Get-ScheduledTaskInfo -TaskName "DailyPRReview" | Select LastRunTime, LastTaskResult

LastTaskResult: 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 user

4.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/env file with chmod 600 + source in script
  • Windows: Get-Credential or 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 agent

Register 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 agent

flock -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 agent

4.5 Catch-Up After Sleep / Boot

  • macOS launchd: StartCalendarInterval catches 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

References

Changelog

  • 2026-05-12 — Initial draft (devAlice M2 seed expansion)

Comments