You wrote a small Python script that does something useful — pulls down currency rates, scrapes a forecast, syncs a folder, posts a daily reminder. You want it to run every 15 minutes on your Mac. You read the previous post on this blog about launchd vs cron and decided to do it the launchd way. Then you sat down to write the plist and discovered that every example online is missing one of the three things you actually need: log output, a working PATH, and failure notification.
Here’s a real, working LaunchAgent plist for the most common case — a Python script that runs every 15 minutes, logs both stdout and stderr to a file, and emails you when it fails. Plus the four mistakes I made the first three times I wrote one.
The plist itself
Save this as ~/Library/LaunchAgents/com.you.fxrates.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.fxrates</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.venvs/fxrates/bin/python</string>
<string>/Users/you/scripts/fxrates.py</string>
</array>
<key>StartInterval</key>
<integer>900</integer>
<key>RunAtLoad</key>
<true/>
<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>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/fxrates.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/fxrates.log</string>
<key>WorkingDirectory</key>
<string>/Users/you/scripts</string>
</dict>
</plist>Five things in there carry weight:
- Use the full path to the venv’s Python interpreter. launchd does not run your shell, does not source
~/.zshrc, has no idea whatpython3means./Users/you/.venvs/fxrates/bin/pythonis required. StartInterval = 900— seconds between runs. 900 = 15 minutes. (For specific times, useStartCalendarIntervalinstead, with hour/minute keys.)RunAtLoad = true— run immediately when the agent loads (or when you log in). Without this, the first run waits the fullStartInterval, which makes debugging painful.PATHinEnvironmentVariables— launchd jobs start with a deeply minimalPATH(basically/usr/bin:/bin). If your script shells out togit,jq, anything from Homebrew, you must add/opt/homebrew/bin(or/usr/local/binon Intel Macs).- Both stdout and stderr go to the same file. Otherwise you’ll get two log files and have to mentally interleave them when debugging.
Load it
# load — register the agent and start it
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.fxrates.plist
# verify
launchctl list | grep fxrates
# 12345 0 com.you.fxrates ← PID, last exit code, label
# trigger a run right now (don't wait for the interval)
launchctl kickstart -p gui/$(id -u)/com.you.fxrates
# watch the log
tail -f ~/Library/Logs/fxrates.log
# unload — remove it
launchctl bootout gui/$(id -u)/com.you.fxrateslaunchctl list‘s second column is the last exit code. 0 means success. Anything else means the script crashed; check the log file. If the column shows a negative number, the job was killed by signal — common cause is a script that exceeds the system’s resource limits.
Email me on failure (the missing piece)
launchd doesn’t have a built-in “notify on failure” mechanism. The cleanest pattern is to wrap the script in a tiny shell wrapper that handles the notification:
# /Users/you/scripts/run-with-notify.sh
#!/bin/bash
LABEL="$1"
shift
"$@"
EXIT=$?
if [ $EXIT -ne 0 ]; then
/usr/bin/curl -fsS \
--data-urlencode "to=you@example.com" \
--data-urlencode "subject=[FAIL] $LABEL exit=$EXIT" \
--data-urlencode "body=See ~/Library/Logs/$LABEL.log on $(hostname)" \
https://your-mailgate.example.com/send
fi
exit $EXITThen change the plist’s ProgramArguments to call the wrapper:
<array>
<string>/Users/you/scripts/run-with-notify.sh</string>
<string>fxrates</string>
<string>/Users/you/.venvs/fxrates/bin/python</string>
<string>/Users/you/scripts/fxrates.py</string>
</array>If you have a Healthchecks.io account, simpler still: have the script curl a heartbeat URL on success and a failure URL on exception. Healthchecks alerts you (email / Slack / SMS) when a heartbeat misses its expected window. That removes the need for the wrapper entirely:
# inside fxrates.py
import requests, sys
HC = "https://hc-ping.com/<your-uuid>"
try:
main() # your actual logic
requests.get(HC, timeout=10)
except Exception as e:
requests.post(f"{HC}/fail", data=str(e), timeout=10)
raiseThe four mistakes I made the first three times
- Forgetting
RunAtLoad. The script “didn’t run.” It was waiting 900 seconds for its first execution. - Hard-coding
python3instead of the venv path. launchd resolved it to system Python which didn’t have the venv’s packages, the script crashed on import, and stdout was empty (because the import error was on stderr and I’d only setStandardOutPath). - Missing
WorkingDirectory. The script reads a config file via relative path. WithoutWorkingDirectory, launchd starts the job in/, the relative path doesn’t resolve, the script crashes silently. Always setWorkingDirectorywhen your script reads or writes relative paths. - Trying to use
~in path strings. launchd does not expand~. Always write/Users/you/.... (Or use$HOMEinEnvironmentVariables, but most plist parsers don’t expand it either — full paths are the safe answer.)
One last debugging trick
If launchctl list shows a non-zero exit code and your log file is empty, the job is failing before stdio redirection takes effect — usually a malformed plist or a missing executable. Run:
plutil -lint ~/Library/LaunchAgents/com.you.fxrates.plist
log show --predicate 'subsystem == "com.apple.xpc.launchd"' --info --last 10m \
| grep -i fxratesplutil -lint catches XML / plist syntax errors. The log show command surfaces launchd’s own diagnostic messages — these usually point right at the missing file or bad UserName directive that’s preventing the job from starting in the first place.
Photo: Hands typing on a MacBook with a Python book by Christina Morillo on Pexels.
