Use a LaunchAgent, not cron. Write a plist in ~/Library/LaunchAgents with
StartCalendarInterval set to a day and an hour, load it with
launchctl bootstrap gui/$(id -u), and it fires on the 1st at 09:00 — or on the
next wake if the Mac was asleep, which is the part cron gets wrong.
The steps
- Write the script first and run it by hand. Absolute paths throughout, and
both streams into a log. A scheduled job does not inherit your interactive shell, so anything
that worked only because of your
PATHwill fail silently at 09:00 on the 1st. - Make it executable.
chmod +x ~/bin/monthly-receipts.sh - Write the plist.
~/Library/LaunchAgents/com.you.monthly-receipts.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.monthly-receipts</string> <key>ProgramArguments</key> <array> <string>/Users/you/bin/monthly-receipts.sh</string> </array> <key>StartCalendarInterval</key> <dict> <key>Day</key><integer>1</integer> <key>Hour</key><integer>9</integer> <key>Minute</key><integer>0</integer> </dict> <key>StandardOutPath</key> <string>/Users/you/Library/Logs/monthly-receipts.log</string> <key>StandardErrorPath</key> <string>/Users/you/Library/Logs/monthly-receipts.log</string> </dict> </plist> - Load it.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.monthly-receipts.plist. If you have seenlaunchctl loadin an older guide, that is why:loadandunloadare listed under "LEGACY SUBCOMMANDS" in the manual. - Test it without waiting a month.
launchctl kickstart -k gui/$(id -u)/com.you.monthly-receipts, then read the log. Do this before you trust it, because the next real chance is in thirty days. - Remove it with
bootout, not by deleting the file:launchctl bootout gui/$(id -u)/com.you.monthly-receipts.
How StartCalendarInterval actually reads
Four keys, all integers, all optional: Minute (0–59), Hour (0–23),
Day of the month (1–31), Weekday (0 and 7 are Sunday) and
Month (1–12). "Missing arguments are considered to be wildcard", so a dict with
only Minute in it runs every hour, and an empty one runs every minute.
One trap, stated plainly in the manual: "If both Day and Weekday are specified, then the job will be started if either one matches the current date." That is an OR, not an AND. "The 1st, but only if it is a Monday" is not expressible this way; test the day inside your script instead.
You can also pass an array of these dicts to get several times a day, and
StartInterval exists for "every N seconds" — but the manual warns the two keys
"are not aware of each other. They are evaluated completely independently by the system."
Why this beats cron on a laptop
A Mac spends most of the month asleep, and that is the whole argument:
Unlike cron which skips job invocations when the computer is asleep, launchd will start the job the next time the computer wakes up. If multiple intervals transpire before the computer is woken, those events will be coalesced into one event upon wake from sleep.
So a monthly cron job on a closed laptop simply did not happen, and nothing tells you. The
same schedule as a LaunchAgent runs when you open the lid, and a job that missed three
intervals runs once rather than three times. If it genuinely has to be 09:00, schedule the
wake separately: sudo pmset repeat wakeorpoweron MTWRFSU 07:00:00.
The two things that fail first
- Permissions. A background job gets no dialog. A script reading a protected folder is refused without explaining itself, so run it once from Terminal and answer the prompt there before you expect it to work unattended.
- Nothing to read afterwards. If the job's only output was stdout and you
set no
StandardOutPath, a month later there is no evidence it ran at all. The log path is not optional in practice.
And then the other half
A schedule starts a thing. It does not keep the thing: the prompt that built it, the folder its output belongs in, the version that worked before last Tuesday's edit.
That is Nico's half of this. Every tool gets one folder outside any session, a data folder that survives every run so what it collects accumulates instead of vanishing, and ten kept versions — on the Mac, on your own files, started from a list.
← All notes