❝ Nico

Notes

How do I make something run every month on a Mac?

A LaunchAgent with StartCalendarInterval, loaded with launchctl bootstrap. The plist, the test command, and the one behaviour that makes it better than cron on a laptop.

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

  1. 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 PATH will fail silently at 09:00 on the 1st.
  2. Make it executable. chmod +x ~/bin/monthly-receipts.sh
  3. 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>
  4. Load it. launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.monthly-receipts.plist. If you have seen launchctl load in an older guide, that is why: load and unload are listed under "LEGACY SUBCOMMANDS" in the manual.
  5. 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.
  6. 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