Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

launchctl

Managing services, daemons, and scheduled jobs on macOS with launchd and launchctl.

launchctl

Managing services, daemons, and scheduled jobs on macOS with launchd and launchctl.

Overview

launchd is the service management framework on macOS and the first userspace process (PID 1). It bootstraps the system, supervises daemons and agents, restarts crashed jobs, and handles on-demand activation via sockets, paths, and timers. It is the rough analogue of systemd on Linux, though the model and vocabulary differ.

Jobs are declared in property-list (plist) files rather than INI-style units. Each job has a unique reverse-DNS Label and is loaded into a domain — the system domain, or a per-user GUI/Aqua session. launchctl is the command-line client that talks to launchd.

Two job kinds exist, distinguished entirely by where the plist lives:

  • Daemons run once per machine in the system domain, as root (or a named user), with no access to a login session or the window server. Background services.
  • Agents run per-user inside a GUI/Aqua session, with access to the user's environment and (optionally) the display. Login items, menu-bar helpers, per-user schedulers.
launchd (PID 1)system domaingui/ domain (perlogged-in user)user/ domainLaunchDaemon:com.example.syncLaunchDaemon:org.postgresql.postgresLaunchAgent:com.example.notifierLaunchAgent:com.example.backuplaunchd (PID 1)system domaingui/ domain (perlogged-in user)user/ domainLaunchDaemon:com.example.syncLaunchDaemon:org.postgresql.postgresLaunchAgent:com.example.notifierLaunchAgent:com.example.backup

Domains and Service Targets

Since OS X 10.10 (Yosemite), launchctl uses domain targets and service targets to address jobs unambiguously. This is the modern syntax and the primary form throughout this sheet.

Key Concepts

A domain target identifies where a job lives:

Domain target Meaning
system/ The system domain — LaunchDaemons, root context
gui/<uid>/ The GUI (Aqua) session for user <uid> — LaunchAgents with display access
user/<uid>/ The user domain for <uid> — non-GUI per-user context
session/<asid>/ A security audit session domain
pid/<pid>/ The domain of a specific running process

A service target is <domain-target>/<service-name>, where the service name is the job's Label. For example:

  • system/com.example.sync
  • gui/501/com.example.notifier

Get your own uid with id -u (commonly 501 for the first user). $UID works in most shells.

# Address a daemon in the system domain
launchctl print system/com.example.sync

# Address an agent in the current user's GUI session
launchctl print "gui/$(id -u)/com.example.notifier"

Modern Verbs

Verb Purpose
bootstrap Load a job into a domain (replaces load)
bootout Unload a job / tear down a domain (replaces unload)
enable Mark a service enabled (persists across reboots)
disable Mark a service disabled (persists across reboots)
kickstart Start (or with -k, restart) a service immediately
kill Send a signal to a running service
print Inspect a domain or service in detail
print-disabled Print a domain's enable/disable override records (both states)
blame Explain why a service is currently running
list List loaded jobs (legacy-flavoured but still current)
dumpstate Dump the complete state of every domain (large)
procinfo Show launchd's view of a running PID
asuser Run a command in another user's bootstrap context
bsexec Run a command in another process's bootstrap context
setenv / getenv Get and set the domain's session environment
limit Show or set resource limits for the domain
config Write persistent domain configuration (needs a reboot)
reboot Restart userspace, log out, or reboot the machine

Legacy Syntax (deprecated but still seen)

The old verbs — load, unload, start, stop, list — operate on plist paths or bare labels and implicitly target the caller's domain. Apple deprecated them in favour of the domain-aware form, but they remain everywhere in older docs, blog posts, and install scripts, and still function.

Legacy command Modern equivalent
launchctl load <plist> launchctl bootstrap <domain> <plist>
launchctl load -w <plist> launchctl bootstrap <domain> <plist> + launchctl enable <target>
launchctl unload <plist> launchctl bootout <domain> <plist>
launchctl unload -w <plist> launchctl disable <target> + launchctl bootout ...
launchctl start <label> launchctl kickstart <domain>/<label>
launchctl stop <label> launchctl kill TERM <domain>/<label>
launchctl list launchctl print <domain>

The legacy -w flag conflated enabling (persistent) with loading (this boot). The modern verbs separate the two: bootstrap/bootout affect the current boot, enable/disable persist.

Plist Locations

Where a plist lives determines whether it is an agent or a daemon, and in which session it runs.

Path Kind Session Owner
~/Library/LaunchAgents Agent This user, on their login The user
/Library/LaunchAgents Agent Every user, on their login Admin
/Library/LaunchDaemons Daemon System, at boot Admin (root)
/System/Library/LaunchAgents Agent Apple-supplied System (SIP-protected)
/System/Library/LaunchDaemons Daemon Apple-supplied System (SIP-protected)
YesNo, runsmachine-wideNeeds a user GUIsession?LaunchAgentLaunchDaemon~/Library/LaunchAgents (one user)/Library/LaunchAgents (all users)/Library/LaunchDaemons (root)YesNo, runsmachine-wideNeeds a user GUIsession?LaunchAgentLaunchDaemon~/Library/LaunchAgents (one user)/Library/LaunchAgents (all users)/Library/LaunchDaemons (root)

Never place or edit your own jobs under /System/Library — those directories are owned by macOS and protected by System Integrity Protection (SIP). Put admin-installed jobs in /Library/....

Anatomy of a plist

Job definitions are XML property lists (a binary1 form also exists; see plist Tooling). The keys below cover the vast majority of real jobs.

Core Keys

Key Type Purpose
Label string Unique job identifier (reverse-DNS); should match the filename
ProgramArguments array argv — executable plus arguments (each a separate element)
Program string Single executable path (use instead of ProgramArguments for argv[0]-only)
RunAtLoad bool Start the job as soon as it is loaded
KeepAlive bool / dict Keep the job running; restart on exit (see below)
StartInterval integer Run every N seconds
StartCalendarInterval dict / array Cron-like scheduling (see below)
WatchPaths array Start the job when any listed path changes
QueueDirectories array Start the job while any listed directory is non-empty
StandardOutPath string Redirect stdout to a file
StandardErrorPath string Redirect stderr to a file
EnvironmentVariables dict Environment for the job
WorkingDirectory string chdir before exec
ThrottleInterval integer Minimum seconds between respawns (default 10)
ProcessType string Scheduling class: Background, Standard, Adaptive, Interactive
UserName / GroupName string Run the job as this user/group (daemons only)

RunAtLoad vs on-demand: with neither RunAtLoad nor KeepAlive, the job is on-demand — launchd only launches it when a trigger fires (socket connection, WatchPaths change, StartCalendarInterval, StartInterval, or an explicit kickstart).

KeepAlive Dictionary Forms

KeepAlive as a boolean (true) restarts the job unconditionally. As a dictionary it restarts conditionally:

Sub-key Type Behaviour
SuccessfulExit bool true: restart only after exit 0; false: restart only after non-zero
Crashed bool true: restart only if the job crashed (killed by signal)
NetworkState bool true: keep alive while the network is up
PathState dict Per-path bool: keep alive while a path does (true) / does not (false) exist
OtherJobEnabled dict Per-label bool: keep alive while another job is loaded
<key>KeepAlive</key>
<dict>
    <key>SuccessfulExit</key>
    <false/>          <!-- restart only on failure (non-zero exit) -->
    <key>PathState</key>
    <dict>
        <key>/etc/myapp/enabled</key>
        <true/>       <!-- run only while this flag file exists -->
    </dict>
</dict>

Further Keys

Beyond the core set, these cover process lifecycle, resource limits, and session placement. Most jobs need none of them; each solves a specific problem.

Key Type Purpose
BundleProgram string Executable path relative to the containing bundle — use for agents shipped inside a .app
LaunchOnlyOnce bool Run at most once per boot; launchd will not respawn it afterwards
ExitTimeOut integer Seconds between SIGTERM and SIGKILL on stop (default is system-defined — 5 on macOS 26; check launchctl print)
AbandonProcessGroup bool Do not kill the job's surviving children when the job itself exits
Umask integer / string umask(2) value — an integer is decimal; a string is octal (see below)
RootDirectory string chroot(2) here before exec
InitGroups bool Call initgroups(3) so the job gets its user's supplementary groups (default true)
Nice integer Scheduling niceness, -20–20
LowPriorityIO bool Mark the job's disk I/O as throttleable
SoftResourceLimits dict Per-job soft setrlimit(2) values
HardResourceLimits dict Per-job hard setrlimit(2) values
LimitLoadToSessionType string / array Restrict which session types the job loads into (see below)
SessionCreate bool Spawn the job in its own security audit session rather than launchd's
EnableTransactions bool The job uses xpc_transaction_begin/end; launchd only kills it when idle
EnablePressuredExit bool The job may be asked to exit under memory pressure
StartOnMount bool Start the job every time a filesystem is mounted (see On-Demand Activation)
Sockets dict Socket-activated launch (see On-Demand Activation)
MachServices dict Register Mach/XPC service names (see On-Demand Activation)
Disabled bool Legacy in-plist disable flag — usually ignored, see below

SoftResourceLimits / HardResourceLimits accept NumberOfFiles, NumberOfProcesses, Core, CPU, Data, FileSize, MemoryLock, ResidentSetSize, and Stack.

<key>SoftResourceLimits</key>
<dict>
    <key>NumberOfFiles</key>
    <integer>16384</integer>
</dict>
<key>ExitTimeOut</key>
<integer>60</integer>          <!-- allow a slow drain before SIGKILL -->
<key>Umask</key>
<string>022</string>           <!-- string => octal; <integer>18</integer> is the same value -->

An Umask integer is decimal. Writing <integer>22</integer> and meaning 022 gets you octal 026, so a file created from mode 0666 lands at 0640 rather than 0644 — silently unreadable by everyone outside the group, which surfaces much later as a permission error in something else. Convert first: echo $(( 8#022 )) (bash and zsh) or python3 -c 'print(0o022)' — both give 18.

Better: Umask also takes a string, and a string is octal. launchd.plist(5) types the key <integer or string> and parses a string with strtoul(3), so a leading 0 selects base 8. <string>022</string> means exactly what it looks like, needs no conversion, and cannot be misread by the next person to open the file — prefer it for anything hand-written.

SockPathMode has no such escape hatch. It is <integer> only, and the man page's "Known bug: Property lists don't support octal, so please convert the value to decimal" belongs to that key specifically — not to Umask.

Disabled in the plist is not the switch you want. launchd keeps its own override database, and launchctl enable / disable write there. Once a label has an override recorded, that override wins and the in-plist Disabled key is ignored. Use the verbs; treat the key as legacy.

LimitLoadToSessionType

Agents default to session type Aqua — the logged-in GUI session. This key changes that, and it is what distinguishes a job that will load into gui/<uid> from one that will load into user/<uid>.

Value Session
Aqua Normal logged-in GUI session (the default for agents)
Background Per-user, no GUI — the user/<uid> domain; loads before login
LoginWindow The login window itself, before any user logs in
System The system domain
StandardIO Non-GUI login (e.g. an ssh session)
<!-- Load for the user even when nobody is logged in at the console -->
<key>LimitLoadToSessionType</key>
<string>Background</string>

An agent with LimitLoadToSessionType set to Background will refuse to bootstrap into gui/<uid> — and vice versa. Bootstrap failed: 5 immediately after adding this key almost always means the domain and the session type disagree.

Full Example: LaunchDaemon

A root-owned background service that runs at boot, restarts on failure, and logs to /var/log.

<?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.example.sync</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/sync-agent</string>
        <string>--config</string>
        <string>/usr/local/etc/sync.toml</string>
    </array>

    <key>UserName</key>
    <string>_sync</string>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <dict>
        <key>SuccessfulExit</key>
        <false/>
    </dict>

    <key>ThrottleInterval</key>
    <integer>30</integer>

    <key>ProcessType</key>
    <string>Background</string>

    <key>WorkingDirectory</key>
    <string>/usr/local/var/sync</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>SYNC_ENV</key>
        <string>production</string>
    </dict>

    <key>StandardOutPath</key>
    <string>/var/log/sync-agent.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/sync-agent.err</string>
</dict>
</plist>

Save as /Library/LaunchDaemons/com.example.sync.plist.

Full Example: LaunchAgent

A per-user agent that runs a backup on a schedule and when a watched directory changes.

<?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.example.backup</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/mike/bin/backup.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>30</integer>
    </dict>

    <key>WatchPaths</key>
    <array>
        <string>/Users/mike/Documents/Important</string>
    </array>

    <key>StandardOutPath</key>
    <string>/Users/mike/Library/Logs/backup.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/mike/Library/Logs/backup.log</string>
</dict>
</plist>

Save as ~/Library/LaunchAgents/com.example.backup.plist.

StartCalendarInterval (the cron analogue)

StartCalendarInterval schedules a job at wall-clock times. Each entry is a dict with any of Minute, Hour, Day (of month), Weekday (0–7, Sunday is 0 and 7), and Month. Omitted fields act as a wildcard (every value). There are no cron-style ranges, lists, or steps (*/5, 1-5, 1,15) within a single field — to express those you supply an array of dicts, one per concrete time.

<!-- Daily at 02:30 -->
<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key><integer>2</integer>
    <key>Minute</key><integer>30</integer>
</dict>
<!-- Every hour, on the hour (Minute fixed, Hour wildcard) -->
<key>StartCalendarInterval</key>
<dict>
    <key>Minute</key><integer>0</integer>
</dict>
<!-- Weekdays (Mon–Fri) at 09:00 — one dict per day -->
<key>StartCalendarInterval</key>
<array>
    <dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Weekday</key><integer>2</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Weekday</key><integer>3</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Weekday</key><integer>4</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Weekday</key><integer>5</integer><key>Hour</key><integer>9</integer><key>Minute</key><integer>0</integer></dict>
</array>
<!-- Every 15 minutes — one dict per quarter-hour (no step syntax) -->
<key>StartCalendarInterval</key>
<array>
    <dict><key>Minute</key><integer>0</integer></dict>
    <dict><key>Minute</key><integer>15</integer></dict>
    <dict><key>Minute</key><integer>30</integer></dict>
    <dict><key>Minute</key><integer>45</integer></dict>
</array>

For simple fixed periods, StartInterval (run every N seconds) is far terser than enumerating times. Use StartCalendarInterval only when you need specific wall-clock alignment.

On-Demand Activation

launchd's defining feature is that it can hold a resource open on a job's behalf and start the job only when something actually arrives. The job stays unloaded (costing nothing) until a client connects, a file changes, or a directory fills.

first connectionfd handed overidle, notransactionsnext connectionClientlaunchd holds thelistenerjob launchedexits; launchd keepslisteningfirst connectionfd handed overidle, notransactionsnext connectionClientlaunchd holds thelistenerjob launchedexits; launchd keepslistening
Trigger Key Fires when
Socket Sockets A client connects to a socket launchd is listening on
Mach / XPC MachServices A client looks up the registered service name
Path change WatchPaths Any listed file or directory is modified
Queue QueueDirectories A listed directory becomes non-empty
Timer StartInterval, StartCalendarInterval The interval elapses / the wall-clock time arrives
Mount StartOnMount Any filesystem is mounted — any, not a nominated one

Socket Activation

launchd creates, binds, and listens on the socket itself, then passes the ready file descriptor to the job. Because launchd owns the listener, connections that arrive while the job is down are queued rather than refused — there is no startup race.

<key>Sockets</key>
<dict>
    <key>Listeners</key>          <!-- arbitrary name; the job asks for it by this name -->
    <dict>
        <key>SockServiceName</key>
        <string>8080</string>     <!-- port number, or a name from /etc/services -->
        <key>SockType</key>
        <string>stream</string>   <!-- stream | dgram | seqpacket -->
        <key>SockFamily</key>
        <string>IPv4</string>     <!-- IPv4 | IPv6 | IPv4v6 -->
    </dict>
</dict>

IPv4v6 asks for a single socket serving both families. For a UNIX-domain socket you do not set SockFamily at all — SockPathName implies it (plus SockPathMode, which is decimal):

<key>Sockets</key>
<dict>
    <key>Listeners</key>
    <dict>
        <key>SockPathName</key>
        <string>/var/run/com.example.sync.sock</string>
        <key>SockPathMode</key>
        <integer>438</integer>    <!-- DECIMAL 438 == octal 0666 -->
    </dict>
</dict>

The job retrieves the descriptors with launch_activate_socket(3), keyed by the dictionary name:

#include <launch.h>

int *fds = NULL;
size_t count = 0;

// "Listeners" must match the key in the Sockets dict
if (launch_activate_socket("Listeners", &fds, &count) == 0) {
    for (size_t i = 0; i < count; i++) {
        // fds[i] is already bound and listening — just accept() on it
        accept_loop(fds[i]);
    }
    free(fds);   // caller owns the array
}

launch_activate_socket is the current API. The older launch_msg / launch_data_t checkin dance is deprecated and does not work for jobs bootstrapped into modern domains — if you find it in a blog post, the post predates 10.10.

RunAtLoad on a socket-activated job is usually a mistake, but not a fatal one: it launches the job once at load time — launchd.plist(5) calls it "launched once at the time the job is loaded" — which defeats the point of waiting for demand. It does not break the descriptor hand-off; launch_activate_socket still returns the listeners on that first launch, and demand can start the job again after it exits. Leave it out unless you actually want an unconditional launch at load.

Mach Services (XPC)

MachServices registers names in the domain's bootstrap namespace. A client connecting to the name with xpc_connection_create_mach_service causes launchd to launch the job.

<key>MachServices</key>
<dict>
    <key>com.example.helper</key>
    <true/>
</dict>
Sub-key form Meaning
<true/> Register the name; launchd launches the job on lookup
HideUntilCheckIn Hide the port until the job checks in — lookups block rather than failing during startup
ResetAtClose Recreate the port when the job's connection to it closes
# Confirm the name is registered in the domain
launchctl print "gui/$(id -u)/com.example.helper" | grep -A5 -i 'endpoints\|mach'

The service name must be in the same domain as the client. A daemon's MachServices name is not visible to a GUI agent's lookup unless the daemon registers in the system domain and the client has the entitlement to reach it.

Path and Queue Triggers

<!-- Run whenever any of these paths is modified -->
<key>WatchPaths</key>
<array>
    <string>/usr/local/etc/sync.toml</string>
    <string>/Users/mike/Documents/Important</string>
</array>

<!-- Run while this directory has anything in it; re-run until it is drained -->
<key>QueueDirectories</key>
<array>
    <string>/usr/local/var/spool/sync</string>
</array>

Apple discourages WatchPaths outright. launchd.plist(5): "Use of this key is highly discouraged, as filesystem event monitoring is highly race-prone, and it is entirely possible for modifications to be missed." Treat a fire as a hint that something changed, never as a guarantee you saw every change — if you need reliable delivery, use QueueDirectories (a file left in a spool directory cannot be missed) or poll on StartInterval.

Beyond that: WatchPaths watches the path, not its contents recursively — a change deep inside a watched directory tree does not necessarily fire. It also fires on any modification including the job's own writes, which is a well-worn way to build an infinite loop. Have the job write elsewhere, or debounce.

QueueDirectories keeps relaunching the job until the directory is empty, so the job must actually consume and remove its input, or it will spin.

Managing Jobs End-to-End

Bootstrap a Daemon

# 1. Place the plist (root-owned, 644 — see Common Issues)
sudo cp com.example.sync.plist /Library/LaunchDaemons/
sudo chown root:wheel /Library/LaunchDaemons/com.example.sync.plist
sudo chmod 644 /Library/LaunchDaemons/com.example.sync.plist

# 2. Load it into the system domain
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.sync.plist

# 3. Confirm it loaded
sudo launchctl print system/com.example.sync

Bootstrap an Agent

# Agents load into the current user's GUI session — no sudo
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.example.backup.plist
launchctl print "gui/$(id -u)/com.example.backup"

Start / Restart

# Start now (idempotent if already running)
sudo launchctl kickstart system/com.example.sync

# Restart: -k kills the running instance first
sudo launchctl kickstart -k system/com.example.sync

# Start and print the resulting PID
sudo launchctl kickstart -kp system/com.example.sync

Stop / Signal

# Graceful stop (SIGTERM). If KeepAlive is set, launchd restarts it.
sudo launchctl kill TERM system/com.example.sync

# Force kill
sudo launchctl kill KILL system/com.example.sync

Unload

# Remove from the domain for this boot
sudo launchctl bootout system/com.example.sync

# Equivalent by path
sudo launchctl bootout system /Library/LaunchDaemons/com.example.sync.plist

Enable / Disable (persist across reboots)

bootout only affects the current boot — a job whose plist sits in /Library/LaunchDaemons reloads at the next boot. To stop it permanently, disable it (recorded in launchd's on-disk override database).

# Permanently disable (survives reboot)
sudo launchctl disable system/com.example.sync

# Re-enable
sudo launchctl enable system/com.example.sync

# Override records for this domain
sudo launchctl print-disabled system

print-disabled is misnamed. It dumps the whole override database, not just the disabled half — every entry comes back as "<label>" => disabled or "<label>" => enabled. Grepping for a label therefore tells you nothing on its own; read the value. launchctl enable does not delete a record either, it flips it to => enabled, and there is no verb that removes one.

disable records the override but does not stop a currently-running job; follow with bootout. Conversely enable does not start the job; follow with bootstrap/kickstart (or reboot).

Inspect

# Full detail: state, PID, last exit, KeepAlive, pending events, env
sudo launchctl print system/com.example.sync

# Why is this job running right now?
sudo launchctl blame system/com.example.sync

# List loaded jobs (label, PID, last exit status)
launchctl list                       # current user's domain
sudo launchctl list | grep example   # find a label

# Dump a whole domain
sudo launchctl print system | less

launchctl list columns are PID, last exit status, Label. A - PID means loaded-but-not-running; a non-zero exit status is a quick failure signal.

The sudo on print, blame, and print-disabled is habit, not a requirement — read-only inspection of the system domain works unprivileged. It is list that genuinely differs: with no domain target of its own, bare launchctl list shows your domain and sudo launchctl list shows the system's. Mutation (bootstrap, bootout, kickstart, enable) does need root, and fails with Operation not permitted without it.

Deeper Inspection

When print and blame are not enough.

# Dump launchd's ENTIRE state — every domain, every job, every endpoint.
# Large (several MB on an idle desktop, more under root); redirect and grep
# rather than paging blind.
sudo launchctl dumpstate > /tmp/launchd-state.txt
grep -n -A20 'com.example.sync' /tmp/launchd-state.txt

# What does launchd know about a running PID? (domain, job label, sandbox)
sudo launchctl procinfo 12345

# Which launchd instance manages the current context?
launchctl managername      # e.g. "Aqua"
launchctl manageruid       # the uid, or 0 in the system domain
launchctl managerpid       # PID of the managing launchd

Running Commands in Another Context

Scripts that run from a daemon (or over ssh) sit in the system domain and cannot reach a user's GUI session — osascript, notifications, and open all fail. asuser re-enters the user's bootstrap context.

# Run inside user 501's GUI bootstrap context, as root
sudo launchctl asuser 501 launchctl print "gui/501"

# The usual idiom: right context AND right user
uid=$(id -u mike)
sudo launchctl asuser "$uid" sudo -u mike osascript -e 'display notification "done"'

# Run in the bootstrap context of another process — PID 1 (launchd) is the
# system context; use loginwindow's PID for a user's GUI context
sudo launchctl bsexec 1 /usr/local/bin/some-tool

asuser changes the bootstrap context, not the uid — the command still runs as root unless you also sudo -u. Getting one without the other is the single most common cause of "works in my terminal, fails from the daemon".

Restarting launchd Itself

# Restart userspace only — every daemon and agent, without a hardware reboot.
# Much faster than a reboot; still logs everyone out.
sudo launchctl reboot userspace

# Other targets: apps | logout | userspace | halt | system
sudo launchctl reboot logout

launchctl reboot userspace is a genuine reboot of everything above the kernel. It is the honest way to test "does my daemon come up cleanly at boot?" without waiting for a full restart — but it is not a no-op, so do not run it on a machine doing work.

Clean Uninstall

Removing the plist alone is not enough — the job stays loaded for this boot, and any disable override outlives the file.

LABEL=com.example.sync
DOMAIN=system                      # or "gui/$(id -u)" for an agent

# 1. Stop and unload for this boot
sudo launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null

# 2. Clear any disable override, or a future reinstall silently never starts
sudo launchctl enable "$DOMAIN/$LABEL" 2>/dev/null

# 3. Remove the job definition
sudo rm -f "/Library/LaunchDaemons/$LABEL.plist"

# 4. Confirm it is gone from the domain, and not left disabled.
#    print-disabled reports both states, so read the value rather than the
#    hit: "=> enabled" is fine, "=> disabled" is not.
sudo launchctl print "$DOMAIN/$LABEL"    # expect "Could not find service"
sudo launchctl print-disabled "$DOMAIN" | grep "$LABEL"

# 5. Tidy up whatever the job left behind
sudo rm -f /var/log/sync-agent.log /var/log/sync-agent.err

Step 2 is the one everyone skips. The override database is keyed by label, not by file, so a label you once disabled stays disabled after the plist is deleted and reinstalled — the new job bootstraps without error and then never runs. launchctl print-disabled <domain> is how you catch it.

Environment, Limits, and launchd Config

Session Environment Variables

launchd holds an environment that it hands to jobs it spawns — including GUI applications, which is why an app launched from the Dock does not see your shell's PATH.

# Set a variable for jobs launched from here on, in this domain
launchctl setenv MY_API_HOST api.internal.example

# Read it back
launchctl getenv MY_API_HOST

# Remove it
launchctl unsetenv MY_API_HOST

setenv affects subsequently launched jobs only — already-running processes keep the old environment. And it does not survive a reboot. To make it stick, set it from a RunAtLoad agent, or better, put the value in the job's own EnvironmentVariables dict where it is declarative and visible in the plist.

Resource Limits

# Show all current limits for the domain
launchctl limit

# Show one
launchctl limit maxfiles
# => maxfiles    256            unlimited

# Set soft and hard (root; current boot only)
sudo launchctl limit maxfiles 65536 65536

The hard limit cannot usefully exceed kern.maxfilesperproc — check it first with sysctl kern.maxfiles kern.maxfilesperproc, and pick a hard limit at or below it. (On a stock macOS 26 desktop that is 92160, so the frequently-copied ... 200000 is above the ceiling.) For a single service, prefer per-job SoftResourceLimits in the plist over raising the global limit: it is scoped, declarative, and survives reboot without a helper daemon.

Persistent launchd Configuration

launchctl config writes to launchd's on-disk configuration and is the supported way to set a domain-wide umask or PATH.

# Default PATH for the user domain
sudo launchctl config user path /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

# umask for the system domain — OCTAL here, unlike the plist key
sudo launchctl config system umask 022

config takes octal; the plist key takes decimal. launchctl config ... umask parses its value with strtoul(3) base 8, so 022 means what it looks like. The Umask plist key is decimal, because property lists cannot express octal. Same word, two bases, a few sections apart — check which one you are editing.

launchctl config requires a reboot to take effect — there is no way to apply it live. It also requires root, and it persists, which makes it easy to forget you set it.

There is no read form: the synopsis is config system | user parameter value with the value mandatory, so launchctl config system umask on its own is just a usage error. To see what is set, read the file launchd persists it to — an implementation detail rather than a supported interface, so treat a miss as inconclusive:

sudo plutil -p /var/db/com.apple.xpc.launchd/config/system.plist
sudo plutil -p /var/db/com.apple.xpc.launchd/config/user.plist

plist Tooling

plutil is the canonical tool for validating and converting property lists. Edit XML plists with any text editor; never hand-edit the binary1 form.

# Validate syntax (run this before every bootstrap)
plutil -lint com.example.sync.plist
# => com.example.sync.plist: OK

# Convert binary -> XML for editing
plutil -convert xml1 SomeBinary.plist

# Convert back to binary (rarely needed for launchd; XML is fine)
plutil -convert binary1 com.example.sync.plist

# Convert to JSON for inspection / scripting (read-only view)
plutil -convert json -o - com.example.sync.plist

# Read a single key
plutil -extract Label raw com.example.sync.plist

Avoid the defaults command for launchd plists. defaults rewrites files in macOS's preferred (binary) format and strips comments, which mangles a hand-authored XML job. Use plutil or a text editor. defaults is for the preferences domain, not job definitions.

Login Items and SMAppService (macOS 13+)

Ventura (macOS 13) added Background Task Management (BTM): every third-party agent and daemon on the machine is enumerated in System Settings → General → Login Items & Extensions, where the user can switch it off. A job the user has toggled off will not run, no matter how correctly it is installed — and launchctl bootstrap still returns success.

user leaves it onuser switches it offAssociatedBundleIdentifiersplist inLaunchAgents /LaunchDaemonsBTM registers abackground itemLogin Items andExtensionsjob runs normallyjob never launches;bootstrap stillsucceedsshown under the appname, not the rawlabeluser leaves it onuser switches it offAssociatedBundleIdentifiersplist inLaunchAgents /LaunchDaemonsBTM registers abackground itemLogin Items andExtensionsjob runs normallyjob never launches;bootstrap stillsucceedsshown under the appname, not the rawlabel

This is the first thing to check when a job that used to work stops running after an OS upgrade.

AssociatedBundleIdentifiers

Without this key, your job appears in the Login Items list under whatever name BTM can scrape — often the developer's signing identity, or the bare label. Users disable things they do not recognise.

<key>AssociatedBundleIdentifiers</key>
<array>
    <string>com.example.SyncApp</string>
</array>

Add it to the agent or daemon plist and the item is grouped under that app's name and icon. It is purely cosmetic to launchd, and decisive for whether the user leaves your job enabled.

Registering from an App Bundle

For anything shipped inside a .app, do not write to ~/Library/LaunchAgents by hand. SMAppService (the ServiceManagement framework, macOS 13+) registers a plist that lives inside the bundle, so install and uninstall follow the app.

Bundle location Factory method Domain
Contents/Library/LaunchAgents/<name>.plist SMAppService.agent(plistName:) Per-user agent
Contents/Library/LaunchDaemons/<name>.plist SMAppService.daemon(plistName:) System daemon (needs admin approval)
Contents/Library/LoginItems/<helper>.app SMAppService.loginItem(identifier:) Helper app at login
The app itself SMAppService.mainApp Relaunch the app at login
import ServiceManagement

let service = SMAppService.agent(plistName: "com.example.notifier.plist")

do {
    try service.register()
} catch {
    // Registration can fail because the user has already denied it
    print("register failed: \(error)")
}

switch service.status {
case .enabled:          break                    // registered and permitted to run
                                             // (an on-demand agent may be idle)
case .requiresApproval: SMAppService.openSystemSettingsLoginItems()
case .notRegistered:    break                    // never registered, or unregistered
case .notFound:         break                    // plist missing from the bundle
@unknown default:       break
}

try? service.unregister()   // clean removal

SMAppService supersedes both SMLoginItemSetEnabled (login-item helpers) and SMJobBless (privileged helper installation), each deprecated in Ventura. New code should not use either.

.requiresApproval is not an error — it is the normal state after registering when the user has not yet said yes. openSystemSettingsLoginItems() takes them to the right pane; there is no API to grant it on their behalf, by design.

Inspecting the BTM Database

# Dump the Background Task Management database. No sudo: it asks
# Authorization Services for the admin right and pops a GUI dialog.
sfltool dumpbtm

# Which items has the user actually switched off?
sfltool dumpbtm | grep -B6 'Disposition:.*\[disabled'

# The daemon that owns it. It is MachServices-activated with RunAtLoad=false,
# so "not running" is its normal idle state — pgrep finds nothing on a
# perfectly healthy machine. Ask launchd, not the process table.
launchctl print system/com.apple.backgroundtaskmanagementd | grep -E 'state =|program ='

sfltool dumpbtm runs on a normal machine — SIP does not have to be disabled (verified on macOS 26.6.2 with SIP enabled). What it does need is the system.privilege.admin right, which it requests through Authorization Services: a GUI dialog appears, and if nobody is there to approve it the command dies with errAuthorizationCanceled: The authorization was cancelled by the user. That makes it useless from a script, a daemon, or an ssh session with no console — which is probably how it acquired its reputation for being blocked.

The output is grouped by UID (-2, 0, and each real user) and gives each item a Type and a Disposition. Disposition is the answer to "did the user switch this off": [enabled, allowed, notified] versus [disabled, allowed, not notified]. Type tells you how the item was installed — a plain plist dropped in a Launch* directory shows up as legacy agent or legacy daemon, while an SMAppService registration appears as agent, app, or login item.

Reading is all you get. There is still no supported CLI to re-enable an item a user has switched off — that is deliberate. (sfltool does have resetbtm, but it resets the whole database rather than one item.)

Homebrew Services

brew services is a thin wrapper over launchctl: it generates a plist from the formula's service block, drops it in a Launch* directory, and bootstraps it. Knowing which directory it chose is most of the debugging.

brew services list                    # name, status, user, plist path
brew services start postgresql@16     # generate plist + bootstrap
brew services stop  postgresql@16     # bootout + remove plist
brew services restart postgresql@16
brew services info  postgresql@16     # detail for one formula
brew services run   postgresql@16     # run once, do NOT install a plist
brew services cleanup                 # prune plists for uninstalled formulae
Invocation Plist written to Domain Runs as
brew services start <f> ~/Library/LaunchAgents/homebrew.mxcl.<f>.plist gui/<uid> You
sudo brew services start <f> /Library/LaunchDaemons/homebrew.mxcl.<f>.plist system root
brew services start <f> over SSH ~/Library/LaunchAgents/homebrew.mxcl.<f>.plist user/<uid> You

Homebrew silently drops to user/<uid> when it cannot reach your GUI session — over SSH without owning /dev/console, under HOMEBREW_SUDO_USER, or whenever uid and euid differ. It warns as it does so ("using user/* instead of gui/* domain!"), and the warning is easy to lose in build output. This is the "works at my desk, not over SSH" case: the service starts, but with no window-server access, and launchctl print "gui/$(id -u)/..." cannot find it. Look in user/$(id -u) instead.

Because it is only launchd underneath, the normal tools work:

launchctl print "gui/$(id -u)/homebrew.mxcl.postgresql@16"
launchctl kickstart -k "gui/$(id -u)/homebrew.mxcl.postgresql@16"

Never mix sudo and non-sudo for the same formula. They write to different directories and different domains, and neither invocation can see the other's plist. The result is two copies of the service fighting over the same port or data directory, with brew services list showing only one of them. Pick one — user agent for development, sudo daemon for something that must survive logout — and stay with it.

A user agent stops when you log out. If a service needs to run headless (a database on a build machine, say), it has to be a sudo-installed daemon.

Logging

There are two distinct logging channels, and they are easy to confuse.

  1. The job's own stdout/stderr go wherever StandardOutPath / StandardErrorPath point. If those keys are absent, the output is discarded (it does not go to the unified log). Set them explicitly for any job you want to debug.

  2. launchd's own messages about the job — load failures, throttling, exit codes — flow through the unified logging system (the os_log framework introduced in macOS 10.12). There is no flat /var/log/system.log story any more for most of this; query it with /usr/bin/log.

# Stream live, filtered to your process
/usr/bin/log stream --predicate 'process == "sync-agent"' --info

# Stream everything launchd says, filtered by your label substring
/usr/bin/log stream --predicate 'process == "launchd"' --info | grep com.example.sync

# Show the last 30 minutes for a process
/usr/bin/log show --predicate 'process == "sync-agent"' --last 30m --info

# Subsystem/category filtering (when the program adopts os_log)
/usr/bin/log show --predicate 'subsystem == "com.example.sync"' --last 1h

Spell it /usr/bin/log in anything launchd runs. zsh has a log builtin; macOS disables it in /etc/zshrc, which only interactive shells read. A bare log show ... therefore works when you type it at a prompt and dies with zsh:log:1: too many arguments in a non-interactive shell — which is exactly how launchd runs your job's script, so the command fails precisely where you need it. bash and sh have no such builtin, but the absolute path is unambiguous everywhere.

Console.app provides a GUI over the same unified log — useful for browsing live messages and crash reports without crafting predicates. Crash reports also land in ~/Library/Logs/DiagnosticReports/ (user) and /Library/Logs/DiagnosticReports/ (system).

SIP, TCC, and Code Signing

Three macOS protection layers routinely interfere with launchd work. A fourth — Background Task Management — is covered under Login Items and SMAppService above.

  • System Integrity Protection (SIP) makes /System/Library/... read-only even to root, and protects Apple's own daemons from being unloaded. You cannot bootout a SIP-protected Apple job, and you cannot install your own plist there. Keep custom jobs in /Library/... (admin) or ~/Library/... (user). SIP status: csrutil status.

  • Transparency, Consent, and Control (TCC) gates access to protected resources (Documents, Desktop, Downloads, the camera, Full Disk Access, etc.). A daemon or agent that touches these can fail silently with Operation not permitted (1) even when file permissions look correct. The fix is to grant the executable (or the controlling app) the relevant permission in System Settings → Privacy & Security, commonly Full Disk Access for backup-style jobs. TCC prompts may not surface for background daemons, so the grant often has to be made manually in advance.

Code Signing, Quarantine, and Notarisation

A third protection layer bites specifically on Apple Silicon: every executable must carry a valid signature to run at all, even an ad-hoc one. A binary you compiled locally is signed automatically by the toolchain; one you downloaded, copied from another machine, or patched after signing is not.

# What is this binary signed with?
codesign -dv --verbose=4 /usr/local/bin/sync-agent

# Ad-hoc sign (the "-" identity) — enough to execute on Apple Silicon
codesign -s - --force /usr/local/bin/sync-agent

# Sign properly for distribution, with a hardened runtime
codesign -s "Developer ID Application: Example Ltd (TEAMID)" \
         --options runtime --timestamp --force /usr/local/bin/sync-agent

# Did the signature survive whatever you did to the file?
codesign --verify --deep --strict --verbose=2 /usr/local/bin/sync-agent

Downloads carry a quarantine flag that Gatekeeper checks on first use:

# Is it quarantined?
xattr -l /usr/local/bin/sync-agent          # look for com.apple.quarantine

# Clear it (only for something you actually trust — prefer notarising instead)
xattr -d com.apple.quarantine /usr/local/bin/sync-agent

# How would Gatekeeper assess it?
spctl -a -t exec -vv /usr/local/bin/sync-agent

Read spctl carefully on a bare CLI binary: it answers "rejected (the code is valid but does not seem to be an app)" for anything that is not an app bundle — Apple's own /bin/sleep included. That is Gatekeeper declining to assess a non-app, not a signing failure. For a command-line tool, codesign --verify plus a quarantine check is the meaningful test; spctl earns its keep on .apps and installers.

Symptom Likely cause Fix
Killed: 9 immediately, no output Invalid or missing signature (Apple Silicon) codesign -s - --force <binary>
Job loads, exits non-zero at once, code-signing error in the log Signature broken by a post-sign edit (strip, install_name_tool) Re-sign after every modification
Bootstrap failed: 5 on a downloaded binary Quarantine / Gatekeeper rejection Clear quarantine, or notarise and staple
Works locally, fails on a colleague's Mac Not the signature travelling badly — an ad-hoc signature survives an unchanged copy. It is Gatekeeper rejecting quarantined code with no Developer ID Developer ID + --options runtime + notarise and staple
# Watch for signing rejections while you reproduce
/usr/bin/log stream --predicate 'process == "taskgated" OR process == "syspolicyd"' --info

Signing is not optional plumbing on Apple Silicon — it is a hard requirement enforced by the kernel. Any build step that rewrites the binary (stripping symbols, rewriting install names, injecting a version string) invalidates the signature, so signing must be the last thing your build does.

Quick Reference

Most-Used Commands (modern, with legacy mapping)

Task Modern Legacy
Load (this boot) launchctl bootstrap <domain> <plist> launchctl load <plist>
Unload (this boot) launchctl bootout <domain>/<label> launchctl unload <plist>
Start now launchctl kickstart <domain>/<label> launchctl start <label>
Restart now launchctl kickstart -k <domain>/<label> —
Stop launchctl kill TERM <domain>/<label> launchctl stop <label>
Enable (persist) launchctl enable <domain>/<label> launchctl load -w <plist>
Disable (persist) launchctl disable <domain>/<label> launchctl unload -w <plist>
Inspect a job launchctl print <domain>/<label> launchctl list <label>
Why is it running? launchctl blame <domain>/<label> —
List jobs launchctl print <domain> launchctl list
List disabled launchctl print-disabled <domain> —
Dump all state launchctl dumpstate > /tmp/state.txt —
Inspect a PID sudo launchctl procinfo <pid> —
Run in a user's context sudo launchctl asuser <uid> sudo -u <user> <cmd> launchctl bsexec
Session env var launchctl setenv KEY value —
Show limits launchctl limit [maxfiles] —
Persistent config sudo launchctl config user path <PATH> —
Restart userspace sudo launchctl reboot userspace —
Validate plist plutil -lint <plist> —
Sign a binary (ad-hoc) codesign -s - --force <binary> —
Homebrew service status brew services list —

Domain Targets

Target Context
system LaunchDaemons (root, machine-wide)
gui/$(id -u) LaunchAgents with display access (logged-in user)
user/$(id -u) Per-user, non-GUI (LimitLoadToSessionType = Background)
pid/<pid> A specific process's domain

Plist Directories

Directory Kind / scope
~/Library/LaunchAgents Agents, this user
/Library/LaunchAgents Agents, all users
/Library/LaunchDaemons Daemons, system
/System/Library/... Apple-owned (do not touch)
<App>.app/Contents/Library/LaunchAgents Bundled agent, registered via SMAppService
<App>.app/Contents/Library/LaunchDaemons Bundled daemon, registered via SMAppService

Common Issues and Solutions

Service won't load (bootstrap fails)

# 1. Validate the plist first
plutil -lint /Library/LaunchDaemons/com.example.sync.plist

# 2. Daemons MUST be root-owned and not group/other-writable
sudo chown root:wheel /Library/LaunchDaemons/com.example.sync.plist
sudo chmod 644       /Library/LaunchDaemons/com.example.sync.plist

launchd refuses to load a system daemon whose plist is owned by a non-root user or is group/world-writable — it reports Path had bad ownership/permissions. The plist must be owned by root:wheel with mode 644 (no write bit for group or other). The same hygiene applies to the executable it launches.

"Input/output error" (errno 5) on bootstrap

This almost always means the job is already loaded in that domain — bootstrap is not idempotent. Bootstrapping a service whose label is already present in the domain fails with Bootstrap failed: 5: Input/output error.

# Tear it down, then bootstrap again
sudo launchctl bootout system/com.example.sync 2>/dev/null
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.sync.plist

Error 5 is something of a launchd catch-all, so if bootout then bootstrap still fails, check the next-likeliest causes: the label is disabled (launchctl enable system/com.example.sync before bootstrapping), the bundle was rejected by code-signing or the App Sandbox, or the program the job runs lives under a TCC-protected path (~/Documents, ~/Desktop, ~/Downloads). Each is a thread to pull rather than a full diagnosis here.

KeepAlive thrash / throttling

A job that exits immediately while KeepAlive is set will be respawned in a tight loop. launchd rate-limits this: a job will not be respawned more than once every 10 seconds by default. Raising ThrottleInterval widens that gap.

<key>ThrottleInterval</key>
<integer>60</integer>   <!-- at most one respawn per minute -->

Watch it happen in the unified log:

/usr/bin/log stream --predicate 'process == "launchd"' --info | grep com.example.sync
# ... "Service only ran for 0 seconds. Pushing respawn out by 10 seconds."
# ... "service spawn deferred by 10 seconds due to throttle"

If the job is meant to be short-lived, drop KeepAlive and use RunAtLoad, StartInterval, or StartCalendarInterval instead.

"Operation not permitted" (SIP / TCC)

Symptom Cause Fix
Cannot bootout an Apple daemon SIP protects /System/Library jobs Not removable; leave it
Operation not permitted writing to ~/Documents etc. TCC Grant the executable Full Disk Access in System Settings → Privacy & Security
Install to /System/Library/... fails read-only SIP Use /Library/... instead
Killed: 9 with no output (Apple Silicon) Invalid or absent code signature codesign -s - --force <binary>

Check SIP with csrutil status. Do not disable SIP to work around this — relocate the job or grant the TCC permission.

Job runs but exits immediately

# Inspect last exit status and reason
sudo launchctl print system/com.example.sync | grep -E 'state|last exit'

# Make sure output is captured, then read it
# (add StandardOutPath/StandardErrorPath to the plist if missing)
tail -f /var/log/sync-agent.err

# Run the command by hand in the job's context to see the real error
sudo -u _sync /usr/local/bin/sync-agent --config /usr/local/etc/sync.toml

Common causes: relative paths (launchd does not inherit your shell's PATH or cwd — use absolute paths and set WorkingDirectory); a missing EnvironmentVariables value the program expects; or the executable bit not set.

StartCalendarInterval not firing

Missed runs during sleep are not silently dropped. launchd.plist(5) is explicit: "Unlike cron which skips job invocations when the computer is asleep, launchd will start the job the next time the computer wakes up." A job scheduled for 02:30 on a machine that sleeps through it fires on wake instead.

The catch is the sentence after it: if several slots elapse before the machine wakes, those events are coalesced into one. A job scheduled every 15 minutes across an eight-hour sleep fires once at wake, not thirty-two times — so a job that assumes one run per slot will under-count. Time spent fully powered off is a separate matter, and no catch-up is promised there.

# Confirm the schedule parsed as expected
sudo launchctl print system/com.example.sync | grep -A5 -i calendar

If a job must actually run at a wall-clock moment rather than at the next wake, wake the machine for it (pmset repeat, or a wake schedule) instead of relying on catch-up. And make the job idempotent, since coalescing means one invocation may have to stand in for several.

StartInterval and StartCalendarInterval are, in the man page's words, "not aware of each other". Setting both gives you two independent triggers, not one merged schedule.

Wrong session: agent has no GUI access

An agent bootstrapped into user/<uid> instead of gui/<uid> runs without window-server access, so anything touching the display (notifications, AppleScript UI, screenshots) fails. For agents that need the desktop, always target gui/$(id -u) and place the plist in a LaunchAgents directory.

Job is enabled and loaded but silently never runs (macOS 13+)

launchctl print shows the job, print-disabled reports it => enabled (or omits it), and it still does not fire. On Ventura and later, check Background Task Management:

System Settings → General → Login Items & Extensions → Allow in the Background

An item switched off there is blocked by BTM, and bootstrap still reports success — there is no error to find in the log. Add AssociatedBundleIdentifiers so the entry carries a name the user recognises, and expect a fresh approval prompt after any change to the job's signing identity.

Reinstalled job never starts, no errors anywhere

A disable override outlives the plist it referred to. Deleting the file and reinstalling leaves the label disabled, and bootstrap succeeds regardless.

sudo launchctl print-disabled system | grep com.example.sync
# => "com.example.sync" => disabled      <-- the value is the answer, not the hit
# => "com.example.sync" => enabled       <-- this one is fine; look elsewhere

sudo launchctl enable system/com.example.sync
sudo launchctl kickstart -k system/com.example.sync

Clear the override as part of uninstalling, not as a debugging step six months later.

Files created with the wrong permissions

An Umask integer is read as decimal: <integer>22</integer> is octal 026, not 022 — so a file created from mode 0666 comes out 0640 instead of 0644. Writing it as a string avoids the trap entirely, because a string is parsed base-8: <string>022</string> is octal 022. SockPathMode is integer-only, so it is always decimal.

Note the inconsistency before you go hunting in the wrong place: the plist key is decimal, but launchctl config <domain> umask parses octal. If the umask is wrong, check which of the two set it.

echo $(( 8#022 )) $(( 8#027 )) $(( 8#077 ))   # => 18 23 63
python3 -c 'print(0o022, 0o027, 0o077)'      # => 18 23 63

Homebrew service running twice, or not at all

brew services and sudo brew services maintain separate installations in separate domains and cannot see each other.

ls -l ~/Library/LaunchAgents/homebrew.mxcl.*
ls -l /Library/LaunchDaemons/homebrew.mxcl.*

If the same formula appears in both, stop it both ways (brew services stop <f> and sudo brew services stop <f>), then start it once, the way you actually want it.

Socket-activated job starts but sees no connections

# Is launchd actually holding the listener?
sudo launchctl print system/com.example.sync | grep -A10 -i 'sockets\|endpoints'

Usual causes: the name passed to launch_activate_socket does not match the key in the Sockets dict; the job calls socket()/bind() itself instead of accepting on the inherited descriptor, so it is listening somewhere launchd is not; or it closes the inherited fd. RunAtLoad is a red herring here — it launches the job early but does not interfere with the hand-off.

osascript / notifications fail when run from a daemon

The system domain has no window server. Re-enter the user's GUI context and drop privileges:

uid=$(id -u mike)
sudo launchctl asuser "$uid" sudo -u mike osascript -e 'display notification "hello"'

asuser alone leaves the command running as root; sudo -u alone leaves it in the wrong bootstrap context. Both are needed.

Related Topics

To complement this launchctl cheatsheet, consider exploring these related topics:

  • systemd - The Linux service manager; the closest analogue, with units, timers, and socket activation covering the same ground
  • macOS Network Tools - Diagnosing the network-facing daemons and agents you have just learned to supervise
  • zsh - The default macOS shell, and where PATH and environment differences between your terminal and launchd first bite
  • SSH & SSH Config - Remote administration of Macs, where the system-domain-versus-GUI-session distinction matters most
  • Shell Scripting - Writing the idempotent, absolute-path scripts that launchd jobs demand
  • Cron & Scheduled Jobs - The scheduling model StartCalendarInterval replaces, and its persistence semantics