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
KeepAliveandThrottleInterval - 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>
Key fields:
-
Label— must be unique, reverse-DNS convention -
StartCalendarInterval— fires at specific time (like cron0 6 * * *) -
StandardOutPath/StandardErrorPath— log files, automatically created -
EnvironmentVariables— inject PATH and any env vars the script needs -
RunAtLoad— whether to fire immediately on load (settruefor 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
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"
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
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>
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
"Service is disabled" error:
launchctl enable gui/$(id -u)/com.whoffagents.atlas-morning
launchctl load ~/Library/LaunchAgents/com.whoffagents.atlas-morning.plist
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:
- One plist per schedule — morning, midday, evening, overnight
-
One shell wrapper —
atlas_wake.sh SESSIONdispatches to the right Claude prompt -
Structured log files —
logs/atlas-YYYY-MM-DD-SESSION.logfor easy tailing -
Environment injection — all secrets in
.env, sourced in the wrapper - 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.
Top comments (0)