Sweepfor Mac

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.

10 min read

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:

  1. 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.”
  2. A domain is a context. system is system-wide, root-owned. gui/<uid> is a logged-in user’s GUI session. user/<uid> is a user, GUI or not.
  3. 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.

Tip: Validate your plist syntax with 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.”

Power users use Sweep tooEven when you know the Terminal commands, Sweep is faster and harder to mess up. Get Sweep free →

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.

Skip the Terminal stackSweep does the same cleanup with a UI and a preview before anything is removed. Download Sweep free →

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.

There’s a GUI for thatSweep wraps the Terminal cleanup in a UI you don’t have to memorize. Try Sweep free →

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 just sleep 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.

← Back to all guides