Mac maintenance
How macOS launchd Jobs Work (a Practical Guide)
A working guide to launchd on macOS — what plists do, the job lifecycle, daemons vs agents, KeepAlive, WatchPaths, and how to debug stuck jobs.
Launchd is the heart of macOS process management. Every daemon, every login agent, every scheduled task on a Mac is a launchd job. Apple wrote it as a replacement for the old init, cron, inetd, and several other tools — the goal being one supervisor that handles “run this when X” for any reasonable definition of X.
The user-facing docs are sparse. This is a practical walkthrough of how launchd actually works and how to write a plist that does what you want.
The mental model
Three concepts:
- A job is a plist file. It describes what to run, when, and how to keep it alive. The file lives in one of a handful of “search paths.”
- A domain is a context.
systemis system-wide, root-owned.gui/<uid>is a logged-in user’s GUI session.user/<uid>is a user, GUI or not. - Launchd manages job lifecycle. It loads plists, starts processes, restarts crashed ones, watches for triggers, and exits cleanly when you say so.
Whenever you “install” a daemon — a Homebrew service, an app’s helper, your own automation — you’re putting a plist somewhere and asking launchd to load it.
The search paths
Launchd reads plists from these directories at startup or when explicitly told to:
/System/Library/LaunchDaemons/— Apple system daemons. Read-only on the sealed system volume./System/Library/LaunchAgents/— Apple user agents./Library/LaunchDaemons/— third-party daemons (run as root)./Library/LaunchAgents/— third-party agents (run as your user, GUI session).~/Library/LaunchAgents/— your personal agents.
For your own automation, ~/Library/LaunchAgents/ is the right home. No sudo needed, no reboot to install, no risk of breaking something system-wide.
Anatomy of a plist
The minimum viable 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>com.you.example</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/bin/example.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
Three required-ish keys:
Label— unique string. Convention is reverse-DNS (com.you.example). Must match the filename minus.plist.ProgramArguments— argv array for the process. First element is the binary path.RunAtLoad(or some other trigger) — what causes it to run.
That’s a plist that runs example.sh once when loaded.
plutil -lint ~/Library/LaunchAgents/com.you.example.plist before trying to load it. Catches typos before launchd silently rejects them.The triggers
A job runs when one of these conditions is true:
RunAtLoad
<key>RunAtLoad</key>
<true/>
Run once when launchd loads the plist. For a user agent, that’s at login. For a daemon, at boot.
StartInterval
<key>StartInterval</key>
<integer>3600</integer>
Run every N seconds. Simplest periodic trigger.
StartCalendarInterval
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>0</integer>
<key>Weekday</key><integer>0</integer>
</dict>
Run on a calendar match. Each key is optional — if you specify only Hour and Minute, it runs every day. Add Weekday (0–7) or Day (1–31) or Month (1–12) to narrow.
For multiple schedules, use an array of dicts.
WatchPaths
<key>WatchPaths</key>
<array>
<string>/Users/you/Downloads</string>
</array>
Run when something changes in any of these paths. macOS uses FSEvents under the hood — fast and efficient.
QueueDirectories
<key>QueueDirectories</key>
<array>
<string>/Users/you/inbox</string>
</array>
Run when a directory has files in it. Once the script processes them and the directory is empty, the trigger resets.
KeepAlive
<key>KeepAlive</key>
<true/>
Restart the job whenever it exits. Combined with RunAtLoad, it makes a process that just always runs. Be careful — if the script exits immediately, this creates a tight respawn loop.
KeepAlive can also be conditional:
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
Restart only on non-zero exit. There are also conditions for network state, file presence, and “the path exists.”
Output and environment
By default, launchd discards stdout and stderr. To capture them:
<key>StandardOutPath</key>
<string>/tmp/example.log</string>
<key>StandardErrorPath</key>
<string>/tmp/example.err</string>
Specify environment variables explicitly — launchd’s environment is minimal:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
For shell scripts, prefer #!/bin/zsh -l (or #!/bin/bash -l) at the top so it loads your shell’s profile and gets the usual PATH and aliases.
Working directory:
<key>WorkingDirectory</key>
<string>/Users/you/Projects</string>
User to run as (daemons only):
<key>UserName</key>
<string>nobody</string>
For agents, the user is implicit (yours).
The lifecycle
Loading a plist:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.example.plist
This registers the plist with launchd. If RunAtLoad is true (or any other immediate trigger), the job runs.
Unloading:
launchctl bootout gui/$(id -u)/com.you.example
Removes the job from launchd’s tracking. The plist file stays.
Disabling without removing:
launchctl disable gui/$(id -u)/com.you.example
Tells launchd “even if this plist is loaded, don’t run it.” The override persists across reboots. Re-enable:
launchctl enable gui/$(id -u)/com.you.example
Manually triggering a run:
launchctl kickstart gui/$(id -u)/com.you.example
Useful for testing without waiting for the schedule. Add -k to kill an in-flight run and restart.
Inspecting what’s loaded
launchctl list
Shows everything in your user’s GUI domain. Three columns: PID, last exit code, label.
launchctl print gui/$(id -u)/com.you.example
Shows the full state — last run, environment, exit reason, the parsed plist as launchd sees it. Way more useful than list.
launchctl print-disabled gui/$(id -u)
Shows every job that’s been disabled in this domain. Good for “why isn’t my job running?”
Daemons vs agents (the distinction matters)
A daemon runs as root, system-wide. It exists whether anyone is logged in. It can do things that need privileges (open privileged ports, modify system state). Lives in /Library/LaunchDaemons/. Loaded into the system domain.
An agent runs as a user, in their session. It doesn’t exist when no one’s logged in. It has access to that user’s GUI (it can open windows, send notifications). Lives in /Library/LaunchAgents/ (system-wide, runs once per logged-in user) or ~/Library/LaunchAgents/ (just you). Loaded into a gui/<uid> or user/<uid> domain.
Rule of thumb: if it needs to interact with the GUI or just your files, write an agent. If it needs to run regardless of login or needs root privileges, write a daemon.
Real example: auto-process new screenshots
Let’s say you want every new screenshot in ~/Desktop to be moved into ~/Pictures/Screenshots.
Script (~/bin/move-screenshots.sh):
#!/bin/bash
mkdir -p ~/Pictures/Screenshots
for f in ~/Desktop/Screenshot*.png ~/Desktop/Screen\ Shot*.png; do
[ -e "$f" ] || continue
mv "$f" ~/Pictures/Screenshots/
done
Plist (~/Library/LaunchAgents/com.you.move-screenshots.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>com.you.move-screenshots</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/you/bin/move-screenshots.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/you/Desktop</string>
</array>
<key>StandardErrorPath</key>
<string>/tmp/move-screenshots.err</string>
</dict>
</plist>
Load:
chmod +x ~/bin/move-screenshots.sh
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.move-screenshots.plist
Now every screenshot you take gets moved automatically. Watch /tmp/move-screenshots.err for any issues.
KeepAlive in detail
The KeepAlive value can be a boolean or a dict:
<key>KeepAlive</key>
<true/>
Always restart on exit.
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
Restart only on non-zero exit (i.e., crashes).
<key>KeepAlive</key>
<dict>
<key>NetworkState</key>
<true/>
</dict>
Run only when network is up. Restart if it comes back.
<key>KeepAlive</key>
<dict>
<key>PathState</key>
<dict>
<key>/Volumes/Backup</key>
<true/>
</dict>
</dict>
Run only when /Volumes/Backup exists. Useful for “do something whenever this drive is plugged in.”
<key>KeepAlive</key>
<dict>
<key>Crashed</key>
<true/>
</dict>
Restart only after a crash (not after a clean exit).
For a job that legitimately exits and shouldn’t relaunch immediately, use StartInterval instead of KeepAlive.
ThrottleInterval
If a job exits immediately and KeepAlive is true, launchd will throttle relaunches to avoid spinning the CPU. The default is 10 seconds — your job won’t relaunch faster than once every 10 seconds. To override:
<key>ThrottleInterval</key>
<integer>30</integer>
If your job sometimes exits in less than 10 seconds for legitimate reasons, you may need to bump the throttle up to avoid log noise.
Process limits
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>1024</integer>
</dict>
<key>HardResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>4096</integer>
</dict>
Equivalent to ulimit -n. Useful for jobs that open many files.
<key>Nice</key>
<integer>10</integer>
Niceness (0 = normal, 19 = lowest priority, -20 = highest). Use a positive value for background work to keep it from interfering with foreground apps.
Process exclusivity
<key>EnableTransactions</key>
<true/>
For “transactional” daemons that handle requests one at a time and shouldn’t be killed mid-transaction. Rarely relevant for personal scripts.
<key>AbandonProcessGroup</key>
<true/>
Don’t kill child processes when the main process exits. Defaults to false (children are killed).
Sockets and on-demand jobs
Launchd’s most powerful feature: launching a job only when something connects to a network socket or opens a file:
<key>Sockets</key>
<dict>
<key>Listener</key>
<dict>
<key>SockServiceName</key>
<string>8080</string>
<key>SockType</key>
<string>stream</string>
</dict>
</dict>
A job with this plist will be launched the first time someone connects to port 8080. macOS uses this for many services — they don’t run unless something needs them.
For most personal automation, you don’t need sockets. They’re more relevant when writing a long-running daemon.
Debugging stuck jobs
Step 1: did launchd accept the plist?
launchctl print gui/$(id -u)/com.you.example
If this returns “Could not find service,” the plist isn’t loaded. bootstrap it.
Step 2: what was the last exit code?
launchctl list | grep com.you.example
The middle column. Anything non-zero means the job exited unsuccessfully.
Step 3: where’s the actual output?
If you set StandardOutPath and StandardErrorPath, check those files. If you didn’t, the output is gone — launchd doesn’t store it anywhere by default.
Step 4: check the unified log:
log show --predicate 'subsystem == "com.apple.xpc.launchd"' --last 30m | grep com.you.example
You’ll see when launchd tried to run the job, when it exited, and any errors launchd itself emitted.
When NOT to use launchd
- For something you want to run exactly once in 2 hours:
at(which is also disabled by default on macOS — sigh) or justsleep 7200 && command. - For “do this when I press a hotkey”: Hammerspoon, Keyboard Maestro, or BetterTouchTool.
- For “run this app at login”: System Settings → Login Items. Friendlier than a plist.
- For one-time automation triggered by a Finder action: Folder Actions or a Shortcuts automation.
Launchd is right for daemons (long-running, supervised) and scheduled scripts. Don’t shoehorn everything into it.
The reward for understanding launchd is that you can teach your Mac to do almost anything on a schedule, in response to almost any event, with built-in supervision and restart behavior. That’s a lot more capability than cron offered, even if the plist syntax is uglier.