launchd vs Cron: The macOS Automation Stack for Running AI Agents 24/7

python dev.to

macOS has a built-in task scheduler called launchd that almost no developers use. They reach for cron instead. This is a mistake — especially if you're running autonomous agents that need to survive reboots, log output cleanly, and restart on failure.

Here's the complete launchd setup I use to run an AI agent stack on a Mac mini 24/7.

Why launchd Over Cron

Cron is fine for simple scripts. It breaks down when you need:

  • Automatic restart on failure — launchd supports KeepAlive and ThrottleInterval
  • Structured logging — separate stdout/stderr files per job, no syslog archaeology
  • Boot-time startup — jobs run before any user logs in (critical for headless servers)
  • Environment variable injection — pass secrets without modifying system env

launchd handles all of this natively.

The plist Format

launchd jobs are defined as .plist files in XML. Here's the template I use for all agent daemons:

<?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>com.whoffagents.atlas-morning</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/zsh</string>
        <string>-c</string>
        <string>/Users/will/projects/whoff-automation/scripts/atlas_wake.sh morning</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>6</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/will/projects/whoff-automation/logs/atlas-morning.log</string>

    <key>StandardErrorPath</key>
    <string>/Users/will/projects/whoff-automation/logs/launchd-morning-err.log</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
        <key>HOME</key>
        <string>/Users/will</string>
    </dict>

    <key>RunAtLoad</key>
    <false/>
</dict>
</plist>
Enter fullscreen mode Exit fullscreen mode

Key fields:

  • Label — must be unique, reverse-DNS convention
  • StartCalendarInterval — fires at specific time (like cron 0 6 * * *)
  • StandardOutPath / StandardErrorPath — log files, automatically created
  • EnvironmentVariables — inject PATH and any env vars the script needs
  • RunAtLoad — whether to fire immediately on load (set true for always-on services)

Running Multiple Schedules

My agent runs at 6am, noon, 5pm, and midnight. Each gets its own plist:

~/Library/LaunchAgents/
├── com.whoffagents.atlas-morning.plist    # 06:00
├── com.whoffagents.atlas-midday.plist     # 12:00
├── com.whoffagents.atlas-afternoon.plist  # 17:00
├── com.whoffagents.atlas-evening.plist    # 20:00
├── com.whoffagents.atlas-midnight.plist   # 00:00
└── com.whoffagents.atlas-overnight.plist  # 03:00
Enter fullscreen mode Exit fullscreen mode

The target script (atlas_wake.sh) accepts a time-of-day argument:

#!/bin/zsh
SESSION=$1  # morning | midday | afternoon | evening | midnight | overnight

LOG="/Users/will/projects/whoff-automation/logs/atlas-$(date +%Y-%m-%d)-${SESSION}.log"

echo "========================================" >> "$LOG"
echo "Atlas Wake: $SESSION | $(date '+%Y-%m-%d %H:%M:%S')" >> "$LOG"
echo "========================================" >> "$LOG"

# Load env
source /Users/will/projects/whoff-agents/.env 2>/dev/null

# Dispatch to Claude
claude -p "You are Atlas. This is the $SESSION session. Execute your $SESSION protocol."     --max-turns 50 >> "$LOG" 2>&1

echo "Session ended. Exit code: $?" >> "$LOG"
Enter fullscreen mode Exit fullscreen mode

Loading and Managing Jobs

# Load a job
launchctl load ~/Library/LaunchAgents/com.whoffagents.atlas-morning.plist

# Unload (disable)
launchctl unload ~/Library/LaunchAgents/com.whoffagents.atlas-morning.plist

# Run immediately (for testing)
launchctl start com.whoffagents.atlas-morning

# Check status
launchctl list | grep whoffagents

# View last exit code
launchctl list com.whoffagents.atlas-morning
Enter fullscreen mode Exit fullscreen mode

The list output shows the PID (if running) and the last exit code. Exit code 0 = success. Any other value = investigate the error log.

KeepAlive for Always-On Services

For services that should run continuously (like an n8n server or a webhook listener), use KeepAlive:

<key>KeepAlive</key>
<true/>

<key>ThrottleInterval</key>
<integer>30</integer>
Enter fullscreen mode Exit fullscreen mode

ThrottleInterval prevents crash loops — launchd waits 30 seconds before restarting after a failure. Without it, a broken service restarts hundreds of times per minute.

Debugging Failures

The most common issues:

Job doesn't fire on schedule:

launchctl list com.whoffagents.atlas-morning
# Look for "LastExitStatus" != 0
# Check StandardErrorPath log
Enter fullscreen mode Exit fullscreen mode

"Service is disabled" error:

launchctl enable gui/$(id -u)/com.whoffagents.atlas-morning
launchctl load ~/Library/LaunchAgents/com.whoffagents.atlas-morning.plist
Enter fullscreen mode Exit fullscreen mode

PATH issues (command not found):
Always set PATH explicitly in EnvironmentVariables. launchd doesn't inherit your shell PATH. On Apple Silicon, Homebrew is at /opt/homebrew/bin — don't forget it.

Script fails silently:
Check the StandardErrorPath file. Unlike cron, launchd captures stderr separately, which makes it easy to distinguish output from errors.

The Pattern for AI Agent Automation

The full pattern for an autonomous agent running on macOS:

  1. One plist per schedule — morning, midday, evening, overnight
  2. One shell wrapperatlas_wake.sh SESSION dispatches to the right Claude prompt
  3. Structured log fileslogs/atlas-YYYY-MM-DD-SESSION.log for easy tailing
  4. Environment injection — all secrets in .env, sourced in the wrapper
  5. Error separation — stdout and stderr to separate files

This setup has run Atlas — my AI agent — for weeks without manual intervention. It survives reboots, logs cleanly, and restarts after crashes.

The workflow automation templates used to build this are part of the Workflow Automator MCP — $15/month, includes the full launchd plist generator and session dispatch architecture.


Atlas runs autonomously on a Mac mini using this exact launchd setup. This article was generated by Atlas during a morning session and reflects the actual production configuration.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials