LaunchAgents are macOS's built-in system for running background tasks. They replace cron, are more capable, and integrate deeply with the OS. Every Mac has hundreds of them running right now.
launchd is PID 1 on macOS — the first process, parent of everything. You give it a job description (a plist file), and it manages the lifecycle: starting, stopping, restarting, scheduling, and resource management.
Two flavors:
- LaunchAgents — run as your user, in your login session. Can access your files, GUI, keychain.
- LaunchDaemons — run as root (or another user), at system boot, no GUI access.
For personal automation, you almost always want LaunchAgents.
| Directory | Who | When Loaded |
|---|---|---|
~/Library/LaunchAgents/ |
You, for you | At your login |
/Library/LaunchAgents/ |
Admin, for all users | At each user's login |
/Library/LaunchDaemons/ |
Admin, system-wide | At boot (as root) |
/System/Library/Launch* |
Apple only | Read-only, don't touch |
A plist is an XML file describing one job. The filename should match the Label (e.g., local.my-task.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>local.my-task</string>
<key>ProgramArguments</key>
<array>
<string>/Users/mike/.local/bin/my-script</string>
<string>--verbose</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
</dict>
</plist>Only two keys are truly required: Label and either Program or ProgramArguments.
<key>RunAtLoad</key>
<true/><key>StartInterval</key>
<integer>1200</integer> <!-- every 20 minutes -->If the Mac is asleep when the interval fires, that run is skipped. The timer resets when the machine wakes.
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>4</integer>
<key>Minute</key>
<integer>0</integer>
</dict>Available keys: Minute (0-59), Hour (0-23), Day (1-31), Weekday (0=Sun, 7=Sun), Month (1-12). Omit a key to mean "any" — so the above runs daily at 4:00 AM.
Unlike cron, if the Mac was asleep during a scheduled time, launchd fires the job on wake. Multiple missed intervals coalesce into one firing.
Multiple schedules:
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>
<dict>
<key>Hour</key><integer>17</integer>
<key>Minute</key><integer>0</integer>
</dict>
</array><key>WatchPaths</key>
<array>
<string>/path/to/watched/file</string>
</array>Job starts whenever any listed path is modified. Useful for triggering actions on config changes. Note: filesystem watching is inherently racy — modifications can occasionally be missed.
<key>QueueDirectories</key>
<array>
<string>/path/to/queue/</string>
</array>Job stays alive as long as the directory is non-empty. Great for work-queue patterns — drop a file in, job processes it, deletes it, goes back to sleep.
<key>StartOnMount</key>
<true/>You can use multiple triggers together. A job with both RunAtLoad and StartInterval runs immediately at login, then repeats on the interval.
For long-running services (not one-shot scripts), use KeepAlive:
<!-- Always restart if it exits -->
<key>KeepAlive</key>
<true/>Or conditionally:
<key>KeepAlive</key>
<dict>
<!-- Restart only if it crashes (non-zero exit) -->
<key>SuccessfulExit</key>
<false/>
</dict><key>KeepAlive</key>
<dict>
<!-- Only run while this file exists -->
<key>PathState</key>
<dict>
<key>/tmp/my-service-enabled</key>
<true/>
</dict>
</dict>KeepAlive implies RunAtLoad. Jobs that exit quickly and repeatedly get throttled (default: at most once per 10 seconds).
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>MY_CONFIG</key>
<string>/path/to/config</string>
</dict>Important: launchd runs jobs with a minimal environment — not your shell profile. If your script needs Homebrew tools, set PATH explicitly either in the plist or in the script itself.
<key>StandardOutPath</key>
<string>/tmp/my-task.log</string>
<key>StandardErrorPath</key>
<string>/tmp/my-task.log</string><key>WorkingDirectory</key>
<string>/Users/mike/code/myproject</string>When launchd stops a job, it sends SIGTERM first, then SIGKILL after a timeout:
<key>ExitTimeOut</key>
<integer>30</integer> <!-- seconds before SIGKILL -->Jobs that exit quickly get throttled. Override the default 10-second cooldown:
<key>ThrottleInterval</key>
<integer>60</integer> <!-- minimum 60s between launches --><key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>4096</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>8192</integer>
</dict><key>Nice</key>
<integer>10</integer> <!-- lower priority, be gentle on CPU --><key>ProcessType</key>
<string>Background</string> <!-- Background, Standard, Adaptive, Interactive -->Background jobs get deprioritized for CPU and I/O — good for maintenance tasks.
launchd can listen on a socket and only launch your job when a connection arrives:
<key>Sockets</key>
<dict>
<key>MyService</key>
<dict>
<key>SockServiceName</key>
<string>8080</string>
<key>SockType</key>
<string>stream</string>
</dict>
</dict>The job calls launch_activate_socket() to receive the file descriptor. This is how Apple implements many system services — zero resource usage until needed.
Start jobs based on hardware events — USB device plugged in, Bluetooth device appears, etc:
<key>LaunchEvents</key>
<dict>
<key>com.apple.iokit.matching</key>
<dict>
<key>USBDevice</key>
<dict>
<key>idVendor</key>
<integer>1234</integer>
<key>idProduct</key>
<integer>5678</integer>
<key>IOMatchLaunchStream</key>
<true/>
</dict>
</dict>
</dict>Only load on certain hardware:
<key>LimitLoadToHardware</key>
<dict>
<key>model</key>
<array>
<string>MacBookPro18,1</string>
</array>
</dict># Load (start managing the job)
launchctl load ~/Library/LaunchAgents/local.my-task.plist
# Unload (stop managing, kills the process)
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/local.my-task.plist# All jobs (huge list)
launchctl list
# Find yours
launchctl list | grep local.
# Output: PID ExitCode Label
# 1234 0 local.my-task (running, PID 1234)
# - 0 local.other-task (idle, last exit 0)
# - 1 local.broken-task (idle, last exit 1 = error)# Run immediately (even if not scheduled yet)
launchctl kickstart gui/$(id -u)/local.my-task
# Kill and restart
launchctl kickstart -k gui/$(id -u)/local.my-taskplutil -lint ~/Library/LaunchAgents/local.my-task.plistlaunchd gives jobs a bare-bones PATH (/usr/bin:/bin:/usr/sbin:/sbin). Your script works in Terminal but fails as a LaunchAgent? It can't find Homebrew, cargo, or anything in /opt/homebrew/bin. Always set PATH:
#!/bin/bash
PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$HOME/.local/bin"launchd manages the process lifecycle. If your script forks and the parent exits, launchd thinks it crashed and may restart it. Just run in the foreground.
Convention: local.my-task for personal, com.company.task for organizational. The label should match the filename.
ProgramArguments works with anything executable — shell scripts, Python, compiled binaries. Just make sure:
- The file is executable (
chmod +x) - The shebang works without your shell profile (
#!/bin/bash, not#!/usr/bin/env zshif zsh isn't on the default PATH)
# 1. Write the plist
vim ~/Library/LaunchAgents/local.my-task.plist
# 2. Validate
plutil -lint ~/Library/LaunchAgents/local.my-task.plist
# 3. Load
launchctl load ~/Library/LaunchAgents/local.my-task.plist
# 4. Test-fire
launchctl kickstart gui/$(id -u)/local.my-task
# 5. Check results
launchctl list | grep my-task
cat /tmp/my-task.log
# 6. After editing the plist, reload:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/local.my-task.plist
launchctl load ~/Library/LaunchAgents/local.my-task.plist| Tool | Best For |
|---|---|
| LaunchAgent | Anything recurring, persistent, or event-driven. The native way. |
| cron | Still works on macOS but deprecated. No wake-from-sleep, no socket activation. |
| Automator/Shortcuts | GUI-driven, good for simple one-offs. Less control. |
| Login Items | Just "run this app at login." No scheduling, no restart. |
| process-compose | Multi-process orchestration (like docker-compose for local services). Complementary, not a replacement. |
LaunchAgents are the foundation. Everything else is either a subset or builds on top.