On a Mac, launchd starts your agent from a small plist on a calendar or an interval and, unlike cron, runs a missed job when the Mac wakes. Wrap the agent in a script that sets its PATH, waits for the network and is safe to run twice. Keep the Mac awake with pmset, or use a Mac mini, which has no lid to close.
Part of the Always-on agents series, post 7 of 33.
People are surprised when I tell them the jobs that publish this blog and check this site run on a laptop. A 2021 MacBook Pro, on macOS 26.2, that goes to sleep when I close it. It is not the machine I would choose for always-on software, and the cloud post in this series says what I would choose. But it is the machine I have, launchd has run those jobs every morning since September, and the one time it failed taught me more than the fifty times it worked.
Everything below is read off this Mac today. The plists are the real ones with my paths shortened, the power settings are the output of the command, and the failure is quoted from the log.
launchd is the Mac's service manager, and it has one habit you need to know
macOS does not use systemd or, in practice, cron. It uses launchd. You describe a job in a property list file, put it in ~/Library/LaunchAgents (for jobs that run as you, while you are logged in) or /Library/LaunchDaemons (for jobs that run at boot, as any user, with nobody logged in), and launchd starts it on the schedule you gave it.
The habit: Apple's own manual page for launchd.plist says that, unlike cron, which skips job invocations when the computer is asleep, launchd will start a missed calendar job the next time the computer wakes up. That sounds like a feature, and it is, right up until the job wakes before the network does. Keep that in mind; I will come back to it with a timestamp.
Two scheduling keys matter. StartCalendarInterval is cron-like: a dictionary of Hour, Minute, Weekday and so on, and the job runs when the clock matches. StartInterval is a number of seconds, and the job runs every N seconds the Mac is awake. My publisher uses the calendar form at 09:05; an agent that should check something every ten minutes would use the interval form.
What our two agents look like
Here is the plist for the publishing job, exactly as installed, with only my home folder path shortened. It runs a shell script at 09:05, writes both output streams to one log, starts in the repository folder, and does not run when loaded, only on schedule.
~/Library/LaunchAgents/ai.leo.account-risk-publish.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>ai.leo.account-risk-publish</string>
<key>ProgramArguments</key><array>
<string>/bin/zsh</string>
<string>/Users/lera/leo/site/run-account-risk-publish.sh</string>
</array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>9</integer><key>Minute</key><integer>5</integer></dict>
<key>StandardOutPath</key><string>/Users/lera/leo/site/.claude/account-risk-launchd.log</string>
<key>StandardErrorPath</key><string>/Users/lera/leo/site/.claude/account-risk-launchd.log</string>
<key>WorkingDirectory</key><string>/Users/lera/leo/site</string>
<key>RunAtLoad</key><false/>
</dict></plist>Notice that it runs /bin/zsh with a script, not the Python program directly. That is deliberate, and it is the first thing I would tell anyone putting an agent under launchd: launchd gives your job a nearly empty environment. No PATH worth the name, none of the functions or aliases from your shell profile. On my machine the gcloud command is a shell function defined in my interactive profile, so inside launchd it simply did not exist. The wrapper script fixes that, and it is also where the network wait lives.
run-account-risk-publish.sh, the wrapper launchd actually runs#!/bin/zsh
# launchd gives a minimal environment, and gcloud is a shell function in my
# interactive profile, so PATH and HOME are set here, not inherited.
export PATH="/Users/lera/leo/google-cloud-sdk/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/lera"
cd /Users/lera/leo/site
echo "===== $(date '+%Y-%m-%d %H:%M:%S %Z') ====="
# launchd fires a missed 09:05 job the moment the Mac wakes, often before Wi-Fi
# is back. On 2026-09-21 that failed on DNS and lost the day's post. Wait up to
# 10 minutes for the two hosts every upload needs, then run; retry once after
# 5 minutes on failure. The publisher is idempotent, so a retry is harmless.
net_up() {
/usr/local/bin/python3 -c 'import socket; [socket.getaddrinfo(h, 443) for h in ("oauth2.googleapis.com", "storage.googleapis.com")]' 2>/dev/null
}
for i in {1..20}; do
net_up && break
echo "network not up yet (try $i/20), waiting 30s"
sleep 30
done
/usr/local/bin/python3 publish-account-risk.py "$@" && exit 0
echo "publisher failed; retrying once in 5 minutes"
sleep 300
/usr/local/bin/python3 publish-account-risk.py "$@"The watchdog that checks the site at 09:40 has a near-identical plist with two extra keys, Nice set to 5 and ProcessType set to Background, so that if I am using the laptop at 09:40 it stays out of my way. Both jobs log to a file under the repository, which is where I look first on any morning something seems off.
The program the wrapper calls has two properties that matter more than any plist key, and I explain them in the first post of this series: it is idempotent, meaning it asks the storage bucket whether a post already exists before uploading, so running it twice does nothing; and it does catch-up, meaning it publishes everything due on or before today, not just today's item, so a day the Mac was asleep heals itself on the next run. The retry in the wrapper is only safe because of the first property.
The morning it fired before the Wi-Fi was back
On 21 September 2026 the Mac was asleep at 09:05. It woke at 09:29, and launchd, doing exactly what the manual says, started the missed job at 09:29:01. The log records the next three seconds: at 09:29:03 the publisher decided the day's post was due, and at 09:29:04 it died on its very first call to the cloud. The error, verbatim from the launchd log:
account-risk-launchd.log, 21 Sep 2026ERROR: (gcloud.storage.cp) There was a problem refreshing your current auth tokens:
HTTPSConnectionPool(host='oauth2.googleapis.com', port=443): Max retries exceeded with url: /token
(Caused by NameResolutionError("Failed to resolve 'oauth2.googleapis.com'
([Errno 8] nodename nor servname provided, or not known)"))Nothing was wrong with the code, the credentials or the cloud. The Mac could not yet resolve a hostname because the Wi-Fi had not reassociated. The job had no retry, so the day's post did not go out until I noticed and ran it by hand at 18:55. That evening I added the loop you saw in the wrapper: up to twenty attempts, thirty seconds apart, to resolve the two hosts every upload needs, and one retry of the whole run five minutes after a failure. It has not lost a day since, and the general lesson is in the first post: an agent that fires into a dead network is not always-on, it is always-off with a schedule.
The power settings on this machine
This is the output of pmset -g on the laptop, as I ran it while writing this.
pmset -g, 20 Oct 2026$ pmset -g
System-wide power settings:
Currently in use:
standby 1
Sleep On Power Button 1
hibernatefile /var/vm/sleepimage
powernap 1
networkoversleep 0
disksleep 0
sleep 0 (sleep prevented by sharingd, powerd, caffeinate, useractivityd)
hibernatemode 3
ttyskeepawake 1
displaysleep 0
tcpkeepalive 1
lowpowermode 0
womp 1The lines that matter for an agent: sleep 0 means the system does not idle-sleep, and the note in brackets says what is currently holding it awake, including a caffeinate process; womp 1 is wake on network access; powernap 1 lets the Mac do some background work while nominally asleep; hibernatemode 3 is the laptop default that keeps memory powered during sleep. What none of these settings prevents is lid sleep: close a MacBook that is not plugged in with a display attached and it sleeps regardless. That is the gap the network wait was built for.
pmset -g assertions shows who is keeping the machine awake right now. On mine it listed a caffeinate assertion with a five-minute timeout, which is the tool I reach for when I want a one-off job to finish: caffeinate -i ./run.sh keeps the Mac from idle-sleeping until the command exits.
Set up your own agent on a Mac, step by step
This is the recipe I would follow for a new agent on a Mac today, derived from ours. It assumes the agent is a script or a program with a command-line entry point; the open-source agents later in this series fit that description.
- Put the agent in one folder with a wrapper script. The wrapper sets PATH and HOME explicitly, changes into the folder, prints a timestamp, waits for the network, then runs the agent. Everything launchd needs to know about your environment lives in this file, not in your shell profile.
- Make the agent safe to run twice. Before it acts, it checks whether the action already happened. Before it posts, sends or writes, it looks. This is what makes a retry harmless and a double wake a non-event.
- Write the plist. Label, ProgramArguments pointing at zsh and the wrapper, a StartCalendarInterval or StartInterval, log paths, WorkingDirectory, RunAtLoad false. Save it as
~/Library/LaunchAgents/com.example.my-agent.plist. - Load it, then run it once by hand through launchd.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.my-agent.plistregisters it;launchctl kickstart -k gui/$(id -u)/com.example.my-agentruns it now, with launchd's environment, which is the only test that counts. Older guides uselaunchctl load -w, which still works. - Read the log, not the terminal. launchd writes nothing to your screen. Everything is in the StandardOutPath file. The first run through launchd is where PATH mistakes show up as command not found.
- Decide what the Mac does when nobody is using it. On a Mac mini:
sudo pmset -a sleep 0 womp 1 autorestart 1so it never idle-sleeps, wakes for network access and comes back after a power cut. On a laptop, accept that it sleeps, and rely on the wake-and-wait behaviour above. - Give the agent its own identity. Not your login. A service account or an API key with a cap, stored in a file the wrapper reads or in the keychain. My default login token expires and cannot refresh itself without a human, which is a failure you only discover in the morning. The permissions post is the longer argument.
- Decide between an agent and a daemon. A LaunchAgent runs inside your logged-in session: good for anything that needs your keychain or your apps, useless on a headless mini where nobody logs in. A LaunchDaemon in
/Library/LaunchDaemonsruns at boot without a login, as the user you name inUserName. For a mini in a cupboard, that is the one.
~/Library/LaunchAgents/com.example.my-agent.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.example.my-agent</string>
<key>ProgramArguments</key><array>
<string>/bin/zsh</string>
<string>/Users/you/agent/run.sh</string>
</array>
<!-- once a day at 07:00; use StartInterval (seconds) for "every N seconds" instead -->
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>0</integer></dict>
<key>WorkingDirectory</key><string>/Users/you/agent</string>
<key>StandardOutPath</key><string>/Users/you/agent/logs/launchd.log</string>
<key>StandardErrorPath</key><string>/Users/you/agent/logs/launchd.log</string>
<key>RunAtLoad</key><false/>
<key>Nice</key><integer>5</integer>
<key>ProcessType</key><string>Background</string>
</dict></plist>/Users/you/agent/run.sh#!/bin/zsh
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export HOME="/Users/you"
cd /Users/you/agent
echo "===== $(date '+%Y-%m-%d %H:%M:%S %Z') ====="
# wait for the network: replace the host with the one your agent calls first
for i in {1..20}; do
/usr/bin/python3 -c 'import socket; socket.getaddrinfo("api.example.com", 443)' 2>/dev/null && break
echo "network not up yet (try $i/20)"; sleep 30
done
./venv/bin/python agent.py "$@"Load, run now, watch, unloadchmod +x ~/agent/run.sh
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.my-agent.plist
launchctl kickstart -k gui/$(id -u)/com.example.my-agent
tail -f ~/agent/logs/launchd.log
# later, to stop it:
launchctl bootout gui/$(id -u)/com.example.my-agentWhy a Mac mini is the right box, and why mine is not
A Mac mini is always powered, has no lid, no battery to protect, draws a few watts idle, and runs the same launchd as my laptop. OpenClaw's own site calls it an excellent always-on host, and the number of people running that project on a mini under a desk is the reason Mac mini appears in the same sentence as agent in so many headlines this year. If I were setting up a personal agent from scratch today, I would buy the base model, put it on Ethernet, set the three pmset flags above, and install the agent as a LaunchDaemon so it survives a reboot with nobody logged in.
Mine runs on a laptop because the laptop was already here, and the honest cost of that choice is the wrapper's network loop and the catch-up logic: twenty lines of care that a machine with no lid would not need. That is the trade. A Mac that sleeps can still host an always-on agent, as long as the agent is built to wake into an unreliable world and finish the job on the next chance.
The product I make runs on Google Cloud rather than on any Mac, and if you would rather see that agent than build one, it is here. The rest of this series, including the Linux and Windows versions of this page, is on one page.
Sources
- Apple Developer Documentation Archive, Creating launchd jobs (Daemons and Services Programming Guide) (accessed October 20, 2026)
- Apple, launchd.plist(5) manual page, as shipped with macOS 26.2 (man launchd.plist) (accessed October 20, 2026)
- OpenClaw, OpenClaw, open-source AI assistant (Mac mini as an always-on host) (accessed October 20, 2026)
Written by Lera Leonteva. Paths in the quoted files are shortened; everything else is as installed on the machine this was written on.