Make custom macOS notifications

A notification you actually want.
macOS
workflow
automation
Author

Lindsay Lee

Published

May 29, 2026

I have been anxiously checking the progress of an application that I submitted for an Exciting Thing. To check the progress, I have go to into a web portal and submit a form with my identifying information. It doesn’t take long, but it’s annoying to repeat everyday. So I got to thinking, maybe there’s a way I can set up a daily notification for this using some web scraping (which I’ve dabbled in a bit) and some macOS notification stuff (which I’ve never tried before).

My bff Claude helped me figure it out. Here is the gist of the notification portion:

notification-example/
├── daily_notification.py                           the script you customise
├── notify.py                                       the macOS notification helper
└── launchd/
    └── com.example.daily-notification.plist        the daily schedule

In this lil repo you need a script for customizing the notification, a script for generating the notification, and a plist file for scheduling it.

daily_notification.py could contain absolutely anything you need, and may need to go along with other scripts. I leave that part up to you. A minimal sample could look like this:

from __future__ import annotations
from datetime import datetime
from notify import notify

def build_message() -> tuple[str, str, str]:
    """
    Return (title, subtitle, body) for today's notification.
    """
    title = "Daily notification"
    subtitle = datetime.now().strftime("%a, %b %d")
    body = "Hello! Replace build_message() with your own code."
    return title, subtitle, body

def main() -> None:
    title, subtitle, body = build_message()
    notify(title, subtitle, body)


if __name__ == "__main__":
    main()

The key detail is that the message should return three strings: the notification title, subtitle, and body.

notify.py is the function used in daily_notification.py to take the text you prepared and turn it into a macOS notification. A minimal example is:

from __future__ import annotations
import subprocess

def _as_applescript_string(s: str) -> str:
    """
    Quote a Python string for use inside an AppleScript "..." literal.
    """
    cleaned = "".join(c if c >= " " else " " for c in s)
    cleaned = cleaned.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{cleaned}"'

def notify(title: str, subtitle: str, body: str) -> None:
    """Pop a single macOS notification banner.

    The first time this runs, macOS may show a one-time prompt asking whether
    to allow "Script Editor" to post notifications — accept it, or enable
    alerts manually under System Settings -> Notifications -> Script Editor.

    `display notification` truncates multi-line bodies in the banner UI, so we
    flatten the body to one line joined by " · " before sending it.
    """
    flat = " · ".join(line for line in body.splitlines() if line.strip())
    script = (
        f"display notification {_as_applescript_string(flat)} "
        f"with title {_as_applescript_string(title)} "
        f"subtitle {_as_applescript_string(subtitle)}"
    )
    # check=False so a notification failure (e.g. permission not yet granted)
    # doesn't crash the calling script.
    subprocess.run(["osascript", "-e", script], check=False)

if __name__ == "__main__":
    notify(
        title="Hello",
        subtitle="Demo notification",
        body="If you can see this, notify.py is wired up correctly.",
    )

Finally, to schedule the whole thing, you need an XML-style script that tells something called launchd what to do. launchd is a tool built into macOS and is apparently analogous to cron on Linux:

<?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>
    <!-- Unique reverse-DNS label. launchctl refers to the job by this name. -->
    <key>Label</key>
    <string>com.example.daily-notification</string>

    <!-- The command to run, argv-style. Use absolute paths.
         REPLACE /Users/YOU/path/to/notification-example with your actual path. -->
    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/python3</string>
        <string>/Users/YOU/path/to/notification-example/daily_notification.py</string>
    </array>

    <!-- Where to run from. Lets daily_notification.py import notify.py. -->
    <key>WorkingDirectory</key>
    <string>/Users/YOU/path/to/notification-example</string>

    <!-- Run every day at 09:00. Change Hour/Minute to whenever you want. -->
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <!-- Capture stdout/stderr so scheduled-run failures aren't invisible. -->
    <key>StandardOutPath</key>
    <string>/Users/YOU/path/to/notification-example/run.out.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/YOU/path/to/notification-example/run.err.log</string>

    <!-- Don't fire just because the plist got loaded. The schedule decides. -->
    <key>RunAtLoad</key>
    <false/>

    <!-- Hint: this is background work, not a foreground UI app. -->
    <key>ProcessType</key>
    <string>Background</string>
</dict>
</plist>

Make sure to update with your own paths and schedule.

After you’ve got your scripts ready, you install and start the automation job with:

cp launchd/com.example.daily-notification.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.daily-notification.plist

If your mac is asleep at the scheduled time, it will run whenever the computer next wakes up.

To trigger an on-the-fly notification to see how it looks you can run:

launchctl kickstart -k gui/$(id -u)/com.example.daily-notification

Congratulations, you have a daily notification! Now, I didn’t yet figure out how to turn this thing off. For that, for now you are on your own.