The script worked by hand and died on the timer
We had a nightly collector job on a Mac. Run the script by hand, worked every time. On the schedule, it died instantly, every night, for days, and said nothing. The reports it was supposed to process just piled up unread until we went looking.
The error, once we finally read the job's log, was ENOENT: the script tried to spawn a command-line tool and the system said no such file. The same tool our shell found without a thought.
Your shell is carrying a toolbox the timer doesn't get
When you run a script by hand, it inherits your shell's PATH, which your dotfiles have spent years stuffing with locations. Homebrew alone adds /opt/homebrew/bin on Apple silicon Macs, and almost every developer tool lives there.
launchd, the Mac's scheduler, hands your job a bare PATH: /usr/bin, /bin, /usr/sbin, /sbin. That is it. No Homebrew, no language version managers, none of your shell setup, because your shell never runs.
Running a script by hand is walking onto the job site wearing your own tool belt. The scheduler ships the script out there with whatever is bolted to the site. Every tool you did not explicitly pack is a tool the job does not have.
So "it works when I run it" plus "it dies on the timer" is not a mystery. It is the signature. The scheduled run is a different, poorer environment, and PATH is the first place they differ.
The fix: absolute paths, then prove it under launchd
Two fixes work. Set a fuller PATH in the job's plist, or write absolute paths for every binary the script touches: /opt/homebrew/bin/jq, not jq. We use absolute paths. They are explicit, they survive being copied into a different job, and they turn "which tool is this actually running" into something you can read off the script.
Then run the job through launchd once before trusting the schedule, with launchctl kickstart, and read its log. Hand-running the script proves the script. Only a launchd run proves the job.
One more trap waits right behind this one. After you edit the plist, kickstart restarts the job with the OLD job definition. Loading a changed plist takes launchctl bootout followed by bootstrap. Skip that and your fix silently never ships, which is how a five-minute repair turns into a second quiet week of failures.
And give the job a voice: log to a file you actually look at, and have it announce failure somewhere a human sits. A scheduled job that fails silently is not automation. It is a rumor that work is getting done.
Cheap rules, learned the slow way. Pack the tools, prove it on the timer, and make it holler when it breaks.