In the previous post I showed how systemd timers replace cron on Debian and Ubuntu servers with concrete advantages: integrated logging, missed execution recovery, declarative dependencies, and resource control. The logic is compelling and the migration is straightforward — as long as you are on a system running systemd. But if your daily routine includes a Mac, it is a different story.

macOS has its own scheduling system, predating systemd and built on a different philosophy. It is called launchd, it has been around since Mac OS X Tiger in 2005, and it is responsible for practically everything that runs in the background on the system — from internal Apple services to that Spotify updater you never asked to install. Despite being the official and recommended way to schedule tasks on a Mac, launchd lives in a kind of blind spot: people coming from Linux tend to reach for cron out of reflex, and Mac users without a sysadmin background do not even know the option exists.

This post shows how to use launchd to schedule a command that runs every day at 7 AM on my Mac — fuqu telegram, which generates a daily briefing of my tasks and sends it to Telegram. The walkthrough serves as a model for any script or command you want to schedule reliably without depending on an open terminal, without cron, and without third-party apps.

cron Works on Mac. But That Is Not How It Is Done.#

cron exists on macOS. You can open Terminal, type crontab -e, and schedule a job exactly as you would on a Linux server. The daemon is there, it works, and nobody removed it. So why not use it?

The first reason is that Apple has considered cron deprecated in favor of launchd since 2005. The daemon remains present for compatibility, but it receives no improvements, does not integrate with modern system mechanisms, and does not appear in any official documentation as the recommended way to schedule tasks. In practice, it works until the day a macOS update changes something and it stops working — and when that happens, there will be no fix because cron has not been an Apple priority for two decades.

The second reason is more immediate: cron on macOS does not know how to deal with the way a Mac actually operates. Macs sleep, close their lids, stay off at night, and turn on in the morning. If a cron job was scheduled for 3 AM and the Mac was asleep at 3 AM, the execution is lost without warning — the same problem cron has on Linux, but made worse by the fact that a personal laptop is off or asleep far more often than a server. launchd has a native mechanism to detect missed executions and fire them as soon as the system wakes up, which by itself justifies the switch for any task that needs to run daily and reliably.

The third reason is the execution environment. cron on macOS runs with a minimal PATH and no access to your shell environment. Scripts that depend on Homebrew binaries in /opt/homebrew/bin, on Python virtual environments, or on environment variables set in .zshrc simply cannot find what they need when run via cron. The classic solution — putting the full PATH on the first line of the crontab or wrapping everything in a script that sources the profile — works, but it is the kind of workaround you need to remember exists every time you add a new script. launchd offers a declarative way to define environment variables per job, without depending on the user’s shell.

None of this means cron will explode if you use it on a Mac. For a simple script that runs while the laptop is open and awake, it gets the job done. But if you want scheduling that survives sleep, with predictable logging and integration with the operating system, launchd is the right tool — and the one Apple expects you to use.

launchd: the systemd of macOS#

LaunchDaemons vs. LaunchAgents#

launchd is the PID 1 process on macOS — the first process the kernel starts and the parent of all others. In that sense, it occupies exactly the same role as systemd on Linux: it manages services, controls boot dependencies, and handles the lifecycle of everything running in the background. The difference is that launchd has been doing this since 2005, nearly a decade before systemd became the standard on Linux distributions.

Tasks managed by launchd fall into two categories that you need to understand before creating anything: LaunchDaemons and LaunchAgents.

LaunchDaemons are system services. They run as root, start during boot before any user logs in, and have no access to the graphical session. Their plist files live in /Library/LaunchDaemons/ (for third-party daemons) or /System/Library/LaunchDaemons/ (for Apple’s own, which you should not touch). They are the equivalent of services you would place in /etc/systemd/system/ on Linux — system-level backups, network services, hardware monitoring.

LaunchAgents are user tasks. They run with the permissions of the logged-in user, start when the user logs in, and have access to the graphical session environment. They exist in two places: /Library/LaunchAgents/ for agents installed by third-party applications that apply to all users, and ~/Library/LaunchAgents/ for personal agents that belong exclusively to your account. That last directory is where everything you create for personal use will live — and it is the closest equivalent to systemd’s user timers in ~/.config/systemd/user/, with a practical advantage: on macOS, personal LaunchAgents work automatically while the user is logged in, without needing any equivalent to loginctl enable-linger.

For scheduling a personal command like fuqu telegram, the choice is straightforward: LaunchAgent in ~/Library/LaunchAgents/. No root needed to create it, no root needed to load it, and it runs in the context of your user session.

The plist Format and the Scheduling Logic#

While systemd uses .ini files with sections and directives, and cron uses a text line with five fields, launchd uses plist files — property lists in XML format. The initial reaction from anyone seeing a plist for the first time is usually surprise: it is verbose, full of tags, and seems disproportionate for what it is doing. But the format is the standard Apple uses for configuration throughout the entire operating system, and after writing the first file the pattern becomes predictable.

A LaunchAgent plist is an XML dictionary with keys describing what to run, when to run it, and how to handle output. Only two keys are mandatory: Label, the agent’s unique identifier (by convention in reverse domain format like com.janio.fuqu-telegram), and ProgramArguments, an array with the command and its arguments. The minimum needed to define a task fits in a few lines — all the XML ceremony is syntactic, not conceptual.

Scheduling is configured with the StartCalendarInterval key, which accepts a dictionary with fields for hour, minute, day of month, month, and day of week. The logic is similar to cron: you specify only the fields that matter and the rest default to “any value.” A dictionary with only Hour and Minute defined means “every day at that time” — the direct equivalent of 0 7 * * * in cron or *-*-* 07:00:00 in systemd’s OnCalendar.

The difference that matters in practice is the behavior when the Mac is asleep or off at the scheduled time. launchd detects that the execution was missed and fires it as soon as the system wakes up or restarts. This behavior is the default — no directive equivalent to systemd’s Persistent=true is needed. If the agent is loaded and the time has already passed, the execution happens at the next opportunity. For anyone scheduling tasks on a laptop that is not powered on 24 hours a day, this guarantee alone is worth the transition from cron to launchd.

Other useful keys that will appear in the practical example include StandardOutPath and StandardErrorPath for directing output to log files, EnvironmentVariables for defining environment variables available during execution, and WorkingDirectory for setting the process’s working directory. Everything declarative, everything in the same file, no wrappers or profile sourcing needed.

Practical Case: FUQU Telegram at 7 AM#

What the Command Does#

FUQU is a personal task manager that runs in the terminal, built in Python with Textual and SQLite. Among other things, it has a telegram subcommand that generates a daily briefing — a summary of pending, overdue, and scheduled tasks for the day — and sends it to a Telegram bot. The briefing is generated by a language model (local LM Studio with fallback to the Cerebras API), formatted in Markdown, and delivered as a message in the bot’s chat. The result is that every day at 7 AM, before I open the laptop, the day’s summary is already waiting in Telegram on my phone.

In the terminal, the command that makes this happen is:

cd ~/Dropbox/fuqu && .venv/bin/python -m fuqu telegram

The project lives in ~/Dropbox/fuqu, uses a local virtualenv in .venv, and is run as a Python module. The cd to the project directory is necessary because FUQU resolves the SQLite database path relative to the working directory. This combination of cd plus the absolute path to the Python interpreter inside the venv is the kind of thing that works perfectly in the interactive shell but breaks when executed outside the user’s context — which is exactly what happens in a LaunchAgent.

The Wrapper Script#

launchd does not execute commands through a shell. It calls the binary directly, without going through .zshrc, without expanding aliases, without inheriting the interactive session’s PATH. For this reason, the cleanest approach is to wrap the command in a dedicated shell script that handles the execution environment.

#!/bin/bash
cd ~/Dropbox/fuqu || exit 1
.venv/bin/python -m fuqu telegram

The script does two things: changes to the project directory (aborting if the directory does not exist) and runs the command with the absolute path to Python inside the virtualenv. It does not depend on aliases, does not depend on PATH, does not depend on anything that exists only in the shell’s interactive session.

The file can live anywhere reasonable. I keep personal scripts of this kind in ~/bin/, a directory that already exists in my interactive PATH but that does not matter here — the plist will reference the absolute path regardless:

mkdir -p ~/bin
cat > ~/bin/fuqu-telegram.sh << 'EOF'
#!/bin/bash
cd ~/Dropbox/fuqu || exit 1
.venv/bin/python -m fuqu telegram
EOF
chmod +x ~/bin/fuqu-telegram.sh

Before involving launchd, it is worth testing the script in isolation to make sure it works outside the interactive shell context:

/usr/bin/env -i HOME="$HOME" ~/bin/fuqu-telegram.sh

The env -i clears all environment variables, simulating the minimal environment that launchd will provide. If the briefing arrives on Telegram, the script is ready.

The plist File#

The LaunchAgent is an XML file in ~/Library/LaunchAgents/. By convention, the name follows the reverse domain format — in my case, com.janio.fuqu-telegram.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.janio.fuqu-telegram</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/janiosarmento/bin/fuqu-telegram.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>7</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/janiosarmento/.local/log/fuqu-telegram.out.log</string>

    <key>StandardErrorPath</key>
    <string>/Users/janiosarmento/.local/log/fuqu-telegram.err.log</string>

    <key>WorkingDirectory</key>
    <string>/Users/janiosarmento/Dropbox/fuqu</string>
</dict>
</plist>

A few notes on each key:

The Label is the agent’s unique identifier within launchd. It must match the file name without the .plist extension. It is what appears in the output of launchctl list and what you use to interact with the agent via command line.

ProgramArguments takes an array with the absolute path to the script. Paths with ~ are not expanded by launchd — always use the full path starting from /Users/. If the command needed additional arguments, each one would be a separate <string> inside the array.

StartCalendarInterval with only Hour and Minute defined triggers the agent every day at 7:00 AM. Omitting the day, month, and day-of-week fields is equivalent to putting * in each — the default is “any value.” If the Mac is asleep at 7 AM, launchd runs the agent as soon as the system wakes up.

The StandardOutPath and StandardErrorPath paths direct output to log files. The directory must exist before the first execution — launchd does not create directories automatically. Without these keys, all script output disappears silently.

WorkingDirectory is redundant with the cd in the wrapper script, but serves as an additional safeguard. If for some reason the cd inside the script fails unexpectedly, the process will still be in the correct directory.

Before loading the agent, create the log directory:

mkdir -p ~/.local/log

Loading and Testing#

With the plist in place and the log directory created, the agent needs to be registered with launchd. macOS offers two sets of commands for this: the older launchctl load and launchctl unload, and the newer launchctl bootstrap and launchctl bootout. The older commands still work and are simpler, so that is what I use:

launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist

To verify the agent was loaded:

launchctl list | grep fuqu

The output shows three columns: the PID (a - if the agent is not running at this moment), the last exit code (0 for success), and the label. Seeing the label in the list confirms that launchd recognized the agent and is monitoring the schedule.

To test without waiting until 7 AM, you can trigger the agent manually:

launchctl start com.janio.fuqu-telegram

The command returns immediately — execution happens in the background. To see the result, check the output log:

cat ~/.local/log/fuqu-telegram.out.log

And the error log, which should be empty if everything went well:

cat ~/.local/log/fuqu-telegram.err.log

If the briefing arrived on Telegram and the logs show no errors, the agent is working. From now on, every day at 7 AM — or the moment you open the laptop if it was asleep at 7 AM — the day’s task summary will appear on Telegram automatically, with no open terminal, no manual intervention.

If you need to change the time or any other setting, the cycle is: unload the agent, edit the plist, reload:

launchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
# ... edit the file ...
launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist

To remove the agent permanently, just unload it and delete the plist file. Without the file in ~/Library/LaunchAgents/, it will not be loaded on the next login.

What launchd Gives You for Free#

StartCalendarInterval and Missed Executions#

In the post about systemd timers, the Persistent=true directive was presented as a concrete advantage over cron: if the system was off at the scheduled time, the timer fires the execution as soon as possible after boot. In launchd, this behavior does not need to be enabled — it is the default. If the Mac was asleep or off at 7 AM, the agent runs as soon as the system wakes up, with no additional key in the plist.

The mechanism is simple: launchd compares the StartCalendarInterval with the current time and the timestamp of the last execution. If it detects that one or more intervals were missed, it triggers the agent immediately. Unlike systemd, which explicitly writes the last execution timestamp to disk when Persistent=true is active, launchd does this tracking internally as part of its normal operation.

In practice, this means the 7 AM briefing arrives on Telegram even if I only open the laptop at 9 AM. The delay is the time the system takes to wake up and load the LaunchAgents — a matter of seconds. For daily tasks like this one, the behavior is exactly what you would expect: the execution happens once per day, at the defined time or at the first opportunity after it, with no duplication and no loss.

There is a subtlety worth knowing: if the Mac was off for several days, launchd fires the agent only once upon waking, not once for each missed day. For a daily briefing this is the correct behavior — receiving five stale briefings at once would be pointless. But for tasks where each missed execution matters individually, like log rotations or metrics collection, this behavior needs to be taken into account.

Logging with stdout and stderr#

cron on macOS inherits the same logging problem it has on Linux: job output goes to the user’s local email via the system’s MTA, which on most personal Macs is not configured. The result is that output simply vanishes, and figuring out whether a job ran — and what it did — becomes a guessing game.

The StandardOutPath and StandardErrorPath keys in the plist solve this directly. All standard output from the process goes to one file, all error output goes to another, both with automatic append. No redirection in the script, no logger, nothing beyond the two lines in the plist.

The separation between stdout and stderr is useful in practice. The output file contains the command’s normal result — in the case of fuqu telegram, confirmation that the briefing was generated and sent. The error file captures Python exceptions, stack traces, connection problems with the LLM or Telegram API, and any other diagnostics the command writes to stderr. When everything works, the error file stays empty, and the fact that it has content is itself a signal that something needs attention.

One limitation compared to systemd is that launchd has no equivalent to journald. Logs are plain text files that grow indefinitely. Managing rotation is up to you — either with a second LaunchAgent that truncates or rotates the files periodically, or with newsyslog, which macOS includes natively and which can be configured to rotate any log file. For tasks that run once a day and produce a few lines of output, growth is negligible and rotation can be something you handle once a year by manually deleting old files. For more verbose or frequent tasks, it is worth setting up rotation from the start.

Environment Variables and PATH#

When you open Terminal on a Mac and type a command, the shell loads .zshrc (or .bash_profile, depending on the setup), sets the PATH with Homebrew directories, exports custom environment variables, and configures everything your workflow needs. None of that exists in a LaunchAgent’s context. launchd runs the process with a minimal environment: HOME, USER, TMPDIR, and little else. The default PATH contains only /usr/bin:/bin:/usr/sbin:/sbin — Homebrew in /opt/homebrew/bin, tools installed via pip or cargo in ~/.local/bin, and any other custom directory simply do not exist.

The wrapper script works around this problem for FUQU’s specific case because it uses the absolute path to Python inside the virtualenv, without depending on PATH for anything. But for agents that call Homebrew binaries or depend on specific environment variables, the plist offers the EnvironmentVariables key:

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    <key>LANG</key>
    <string>en_US.UTF-8</string>
</dict>

Each variable is a key-value pair inside the dictionary. The PATH can be extended to include any necessary directory, and variables like LANG ensure character encoding behaves the same way as in the interactive terminal — something that matters when the command produces output with accented or special characters.

The declarative approach has an advantage over cron’s pattern of putting PATH=... at the top of the crontab: each agent has its own set of variables, isolated from the others. An agent that needs a specific Python version can point to a different PATH than one that uses Node, without conflict. In cron, the PATH set in the crontab applies to all jobs for that user.

Troubleshooting: When the Agent Does Not Run#

launchctl and the Commands You Will Use#

launchctl is the command-line interface to launchd, and in practice you will use a handful of subcommands repeatedly. It is worth having them handy because the launchctl man page is extensive and not always friendly.

To see all agents loaded in your user session:

launchctl list

The output has three columns: PID, last exit code, and label. A PID showing - means the agent is not running at this moment, which is normal for agents based on StartCalendarInterval that execute and exit. An exit code of 0 indicates success on the last run; any other number points to a problem.

To check the status of a specific agent without filtering with grep:

launchctl print gui/$(id -u)/com.janio.fuqu-telegram

The print subcommand shows detailed information: the agent’s current state, the plist path, the last exit code, log paths, and scheduling conditions. The gui/$(id -u) prefix identifies the logged-in user’s domain — it is the equivalent of saying “the agent that belongs to me, not to the system.”

To trigger a manual execution outside the scheduled time:

launchctl start com.janio.fuqu-telegram

To unload an agent (removes it from launchd but does not delete the file):

launchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist

To reload after editing the plist:

launchctl unload ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist
launchctl load ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist

There is no launchctl reload. Unloading and loading again is the standard workflow, and it must be done in that order — trying to load an agent that is already loaded results in an error.

Common Errors and Where to Look#

The most frequent error when creating a LaunchAgent for the first time is the agent simply doing nothing. No message, no log, no sign of life. The diagnosis follows a predictable sequence.

The first step is to confirm the agent is loaded. If launchctl list | grep fuqu returns nothing, launchd does not know the agent exists. The most common causes are the plist file outside the correct directory (it must be in ~/Library/LaunchAgents/, not ~/LaunchAgents/ or any other variation), incorrect file permissions (it should belong to your user, with 644 permissions), or malformed XML. The plutil command validates the plist syntax without loading it:

plutil -lint ~/Library/LaunchAgents/com.janio.fuqu-telegram.plist

If plutil reports an error, the problem is structural in the XML — an unclosed tag, a wrong type, a key outside the main dictionary. plutil indicates the line and nature of the error, which is usually enough to find the problem.

The second step, if the agent is loaded but the exit code is not zero, is to check the logs. If StandardOutPath and StandardErrorPath are configured in the plist, the error file’s content usually reveals the problem immediately. If the log files exist but are empty, the process may be terminating before producing any output — which points to a problem in the command itself or the wrapper script.

If the log files were not even created, the most likely cause is that the parent directory does not exist. launchd does not create intermediate directories — if ~/.local/log/ does not exist, logs simply are not written, and launchd does not warn that this happened. Creating the directory and triggering the agent manually with launchctl start usually resolves it.

The third failure pattern is the command working in Terminal but failing in the LaunchAgent. This is almost always an environment problem. The script depends on something that exists in the shell’s interactive session but not in launchd’s context — a PATH that includes Homebrew, an environment variable exported in .zshrc, a service that only runs when Terminal is open. The env -i test described in the previous section exists to catch this kind of problem before involving launchd, but if the agent is already created and failing, the diagnosis is the same: run the wrapper script with env -i HOME="$HOME" and see what breaks.

Other errors that appear less frequently include the Label in the plist not matching the file name (launchd is tolerant of this in most cases but it can cause unexpected behavior), ProgramArguments using ~ instead of the absolute path (launchd does not expand tilde), and the wrapper script lacking execute permission (chmod +x fixes it). In all these cases, launchctl print for the agent usually has the information needed to reach the cause — it is worth getting used to it as the first diagnostic tool instead of trying to guess what went wrong.

Comparing with systemd Timers#

Anyone who read the previous post and made it this far has probably noticed that launchd and systemd timers solve the same fundamental cron problems — logging, missed executions, environment variables, dependencies — but with different approaches that reflect their respective operating systems’ philosophies.

The separation of concerns is where the similarity is most direct. systemd splits the task into two files: a .service defining what to run and a .timer defining when. launchd puts everything in a single plist, with keys for the command and for the schedule in the same file. In practice, the systemd approach is more flexible — the service can be tested, monitored, and controlled independently of the timer, and the same service can be triggered by multiple timers or by other system events. The launchd plist is more self-contained and simpler to move between machines, but it couples the task definition to its schedule.

Logging is where systemd has a clear advantage. journald centralizes all output from all services in a structured logging system, with filters by unit, time, and priority, and with automatically managed persistence. launchd writes to plain text files that you need to rotate yourself. For a personal agent that runs once a day, the difference is irrelevant. For a server with dozens of scheduled tasks, the absence of a centralized journal on macOS is felt.

Missed execution recovery works in both systems but through different mechanisms. In systemd it is opt-in via Persistent=true — if you do not declare the directive, missed executions during downtime are ignored, exactly like cron. In launchd, recovery is the default behavior and cannot be disabled. Apple’s choice makes more sense for laptops that sleep and wake constantly; systemd’s makes more sense for servers where the administrator may have good reasons for not wanting a missed task to run outside its scheduled window.

Resource control and sandboxing is systemd’s exclusive territory. Directives like CPUQuota, MemoryMax, ProtectHome, and PrivateTmp have no direct equivalent in launchd for user LaunchAgents. macOS has its own sandboxing mechanisms (App Sandbox, seatbelt profiles), but they are aimed at applications distributed through the App Store, not at personal scripts in ~/Library/LaunchAgents/. In practice, a LaunchAgent runs with the user’s full permissions, without any additional restrictions — similar to what cron does on both systems.

Scheduling syntax is more expressive in systemd. The OnCalendar format accepts ranges like Mon..Fri, repetitions like *:0/15, multiple directives stacked in the same timer, and the ability to validate everything beforehand with systemd-analyze calendar. launchd’s StartCalendarInterval is functional but limited: it accepts fixed values for each field, with no ranges or repetitions in the same dictionary. Scheduling a task for weekdays at 6 PM requires five separate entries in the plist (one per day), while in systemd it is a single line. For simple schedules like “every day at 7 AM,” both solve it with equal ease.

Where launchd has no rival is integration with the macOS lifecycle. It natively understands system sleep and wake, knows when the user has logged in and out, and can condition an agent’s execution on events like a volume being mounted or a connection to a specific network. systemd timers can depend on system targets and units, but the vocabulary of available events on Linux is different — geared toward servers that stay on around the clock, not laptops that open and close their lids thirty times a day.

In the end, the practical recommendation is the same for both systems: use the native tool. On Linux servers, systemd timers. On Mac, launchd. cron continues to exist on both, continues to work, and continues to be the quickest choice for a throwaway job you need to run in the next two hours. But for any task that will become part of the system’s daily routine — like a briefing that needs to arrive on Telegram every morning, rain or shine, laptop open or not — the native scheduler delivers reliability that cron simply cannot match.

And if instead of scheduling by time you want to react to file changes — automatic backup when a SQLite database is modified, image conversion when files appear in a folder — I wrote a post about WatchPaths, the other side of launchd.