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.

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

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)

Keep reading