A real launchd LaunchAgent: the plist for run this Python script every 15 minutes, log to a file, and email me on failure

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 what python3 means. /Users/you/.venvs/fxrates/bin/python is required.
  • StartInterval = 900 — seconds between runs. 900 = 15 minutes. (For specific times, use StartCalendarInterval instead, with hour/minute keys.)
  • RunAtLoad = true — run immediately when the agent loads (or when you log in). Without this, the first run waits the full StartInterval, which makes debugging painful.
  • PATH in EnvironmentVariables — launchd jobs start with a deeply minimal PATH (basically /usr/bin:/bin). If your script shells out to git, jq, anything from Homebrew, you must add /opt/homebrew/bin (or /usr/local/bin on 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.fxrates

launchctl 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 $EXIT

Then 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)
    raise

The four mistakes I made the first three times

  1. Forgetting RunAtLoad. The script “didn’t run.” It was waiting 900 seconds for its first execution.
  2. Hard-coding python3 instead 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 set StandardOutPath).
  3. Missing WorkingDirectory. The script reads a config file via relative path. Without WorkingDirectory, launchd starts the job in /, the relative path doesn’t resolve, the script crashes silently. Always set WorkingDirectory when your script reads or writes relative paths.
  4. Trying to use ~ in path strings. launchd does not expand ~. Always write /Users/you/.... (Or use $HOME in EnvironmentVariables, 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 fxrates

plutil -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.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.