launchd is a great scheduler and a terrible narrator. When a job fails it usually says nothing at all. These are the four failures that cost me the most time on a Mac mini that runs a small server and a few daily jobs.

The one command to remember for all of them:

$ launchctl print gui/$(id -u)/com.example.job      # a LaunchAgent
$ sudo launchctl print system/com.example.job       # a LaunchDaemon

Look at state and last exit code.

1. The script lives in ~/Desktop (or Documents, or Downloads)

I reproduced this one just now with a two-line script:

$ cat ~/Desktop/launchd-desktop-test.sh
#!/bin/bash
echo ran-ok

$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.desktop-test.plist
$ launchctl print gui/$(id -u)/com.example.desktop-test | grep "last exit"
	last exit code = 126
$ cat /tmp/desktop-test.log
/bin/bash: /Users/me/Desktop/launchd-desktop-test.sh: Operation not permitted

The same script runs fine from Terminal. ~/Desktop, ~/Documents and ~/Downloads are privacy-protected folders; Terminal has been granted access to them, a job started by launchd has not. If you didn’t set StandardErrorPath, the only trace is that exit code.

Interestingly, a job whose WorkingDirectory is on the Desktop but whose program lives elsewhere ran fine in my test — it’s reading the script file that fails.

Fix: keep anything launchd runs outside the protected folders — a project directory under your home, ~/bin, /usr/local/bin. (A different symptom of the same protection, a job hanging while it opens ~/Downloads, is in the previous post.)

2. The LaunchAgent disappeared when nobody was logged in

A LaunchAgent in ~/Library/LaunchAgents is loaded into your login session (gui/<uid>). It runs while that session exists. If the session ends — you log out, or the GUI session crashes and the Mac sits at the login window — every agent in it stops, and nothing is written to your job’s error log, because the job didn’t crash. It just isn’t there.

From across the room a Mac at the login window looks exactly like a Mac that is working. I lost a home server for two days this way before noticing.

Fix: a service that must survive logouts belongs in /Library/LaunchDaemons. You can still run it as your normal user — that’s what UserName is for — so files it creates keep the right owner:

<key>UserName</key><string>me</string>
<key>EnvironmentVariables</key>
<dict>
  <key>HOME</key><string>/Users/me</string>
  <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

Daemons start with an almost empty environment, so set HOME explicitly — many tools look for their config under $HOME and fail in confusing ways without it.

3. The daemon can’t use the login keychain

Moving to a daemon fixed uptime and broke one feature: the server shells out to a command-line tool that keeps its login token in the login keychain. From the daemon, the tool reported:

Not logged in · Please run /login

…and exited 1, while the server itself kept running happily. Setting HOME and USER did not help.

The reason: a system daemon runs outside any login session. You can see it:

$ id -A        # from Terminal
auid=501
$ id -A        # from the daemon
auid=-1

The login keychain belongs to the login session, so it isn’t in the daemon’s keychain search list, no matter which user the daemon runs as.

Fix (without giving up the daemon): when that one tool is needed, hand the work into the user’s session. A daemon running as the same uid is allowed to bootstrap a short-lived job into gui/<uid>; there the keychain opens normally.

#!/usr/bin/env bash
# run-in-session.sh CMD ARGS... — run CMD inside the logged-in user's session.
set -euo pipefail
if [ "$(id -A | head -1)" != "auid=-1" ]; then exec "$@"; fi   # already in a session

LABEL=com.example.in-session
ROOM="$(mktemp -d)"; trap 'rm -rf "$ROOM"' EXIT
cat > "$ROOM/stdin"
{ printf '#!/bin/bash\n'; printf '%q ' "$@"
  printf '< %q > %q 2> %q\necho $? > %q\n' \
    "$ROOM/stdin" "$ROOM/out" "$ROOM/err" "$ROOM/code"; } > "$ROOM/run"
chmod +x "$ROOM/run"
cat > "$ROOM/job.plist" <<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>$LABEL</string>
  <key>ProgramArguments</key><array><string>$ROOM/run</string></array>
  <key>RunAtLoad</key><true/>
</dict></plist>
PLIST
GUI="gui/$(id -u)"
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
launchctl bootstrap "$GUI" "$ROOM/job.plist"
while [ ! -s "$ROOM/code" ]; do
  launchctl print "$GUI/$LABEL" >/dev/null 2>&1 || { sleep 0.3; [ -s "$ROOM/code" ] || break; }
  sleep 0.2
done
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
cat "$ROOM/out" 2>/dev/null || true
cat "$ROOM/err" >&2 2>/dev/null || true
exit "$(cat "$ROOM/code" 2>/dev/null || echo 70)"

The caller sees the same contract as the original tool: arguments in, stdin in, stdout/stderr/exit code out. If nobody is logged in, only this one feature fails — the rest of the daemon stays up, which is the trade I wanted.

Two notes:

  • Read stdout too. The tool printed “Not logged in” on stdout and nothing on stderr. If your server only logs stderr, you’ll just see “exited with 1”.
  • Don’t “fix” this by turning the daemon back into an agent. That trades a broken button for trap #2.

4. launchd’s PATH is not your PATH

Jobs get a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin). Homebrew’s /opt/homebrew/bin is not in it, so ffmpeg, node or python3 from Homebrew “aren’t installed” as far as your job is concerned.

Fix: use absolute paths in ProgramArguments, or set PATH in EnvironmentVariables (as above). For tools whose location you discover at install time, write the resolved path into the plist when you install the job, rather than hoping it’s on PATH at run time.

Checklist

Symptom Likely trap
last exit code = 126, “Operation not permitted” script in a privacy-protected folder
Job simply isn’t running, no errors anywhere LaunchAgent and the login session ended
A CLI says “not logged in” only under launchd daemon outside the session, no login keychain
“command not found” only under launchd minimal PATH

Tested on macOS 26.5. Trap 1 reproduced for this post; traps 2–4 are from running a home server on the same machine.