LaunchMon

LAUNCHD FIELD NOTES

Run a script when a file or folder changes on a Mac: launchd WatchPaths and QueueDirectories

A LaunchAgent with WatchPaths starts your script when a watched file or folder changes; QueueDirectories runs it while a folder has files in it. A working plist, how to load it, and the gotchas: bursts of starts, no argument saying what changed, the 10-second throttle.

macOS has no cron-style file trigger, but launchd has two keys for the job. WatchPaths starts a job when any listed path is modified. QueueDirectories starts a job whenever a listed directory has something in it, and keeps starting it until the directory is empty. Both go in an ordinary LaunchAgent; nothing else has to run.

Start in Terminal

These examples use a placeholder label. Save this as ~/Library/LaunchAgents/local.watch-notes.plist with your own paths. Every path must be absolute: launchd does not expand ~ or $HOME, and the script gets launchd’s short PATH, not your shell’s.

<?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>local.watch-notes</string>
  <key>ProgramArguments</key>
  <array><string>/Users/you/bin/sync-notes.sh</string></array>
  <key>WatchPaths</key>
  <array><string>/Users/you/Notes</string></array>
  <key>StandardOutPath</key><string>/Users/you/Library/Logs/local.watch-notes.log</string>
  <key>StandardErrorPath</key><string>/Users/you/Library/Logs/local.watch-notes.log</string>
</dict>
</plist>

Check it, load it, and confirm launchd registered the watch. The trigger appears under event triggers as com.apple.launchd.WatchPaths, with the paths you gave:

plutil -lint ~/Library/LaunchAgents/local.watch-notes.plist
chmod +x /Users/you/bin/sync-notes.sh
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/local.watch-notes.plist
launchctl print "gui/$(id -u)/local.watch-notes" | grep -A8 "event triggers"

Then change something in the folder and watch the log and the run counter:

touch /Users/you/Notes/test.md
launchctl print "gui/$(id -u)/local.watch-notes" | grep -E "runs =|last exit"
tail -n 20 /Users/you/Library/Logs/local.watch-notes.log

For a drop folder, swap the trigger for QueueDirectories. The script must empty the folder, by processing and deleting the files or moving them elsewhere; if anything is left when it exits, launchd starts it again:

  <key>QueueDirectories</key>
  <array><string>/Users/you/Inbox</string></array>

Apple uses the same pattern itself: the Postfix daemon’s plist queues on /var/spool/postfix/maildrop so that mail dropped there wakes the server.

What to check next

The mechanism is simpler than people expect, and most surprises come from that:

To test the script without touching the folder, run it under launchd directly with launchctl kickstart -k "gui/$(id -u)/local.watch-notes". If it works there but not from a change, the problem is the trigger; if it fails there too, it is the usual environment and working-directory problems.

Why it happens

WatchPaths and QueueDirectories are triggers, not a file-watching service. launchd registers the paths with the system’s file event monitor, and when it hears about a change it starts the job the same way a StartCalendarInterval would. It does not keep the details, so the job learns only that it was started. The launchd.plist manual goes as far as calling WatchPaths “highly discouraged” for being race-prone. That is a warning about relying on every individual change, not about the pattern: for “re-run this when something in here changes” it is still the simplest built-in tool. When you do need every event with its path and kind, run a watcher such as fswatch, or your own FSEvents program, as a KeepAlive agent instead, and let it decide what to do.

User agents usually run in gui/<your uid>; system daemons run in system. The same label in different domains refers to different service registrations.

See it in LaunchMon

LaunchMon lists each job’s triggers, including the exact WatchPaths and QueueDirectories entries, next to its launch count and last exit, so after a save you can see whether the job started once or several times and what it exited with, and open the plist or the configured log from the same pane.

LaunchMon showing a user agent whose trigger is WatchPaths on a project folder, with its launch count and log shortcuts.
LaunchMon with fictional demo services.

Download LaunchMon free trial

Related guides

References: Apple: creating launchd jobs. For commands on your macOS version, run man launchctl and man launchd.plist.