Native macOS Notifications from the Command Line (Without terminal-notifier)
If you’ve ever wanted to send a macOS notification from a shell script, you’ve almost certainly been pointed at terminal-notifier.
It works — but there’s a small detail that always bothered me:
The last commit was in 2017.
For a simple use case, that felt like overkill.
This article documents a small alternative: a tiny native Swift notifier, built specifically for macOS, with no external dependencies, and callable from any command-line script.
It started life as a notifier for Claude Code hooks, but quickly turned into something more general.
Full disclosure: I’m not a Swift developer. I used Claude Code to help me build this — it handled the heavy lifting while I learned how macOS apps are structured. The result is a project small enough that I can actually understand what every line does.
Why not just keep using terminal-notifier?
A few reasons:
- The project appears effectively unmaintained (last commit: 2017)
- It’s commonly installed as a Ruby gem (and Ruby toolchains have a habit of breaking at inconvenient times)
- It solves a problem macOS already has native APIs for
None of these are deal-breakers, but together they made me think:
“This feels like a perfect excuse for a small Swift project.”
Design goals
From the start, the goals were intentionally modest:
- macOS only
- Native Notification Center integration
- Default system notification sound
- Correct app icon in notifications
- Support for image attachments
- Activate a specific app when clicked (for IDE integration)
- No runtime dependencies
- Simple enough to understand in one sitting
From Claude Code-specific to general-purpose
The original motivation was Claude Code.
Claude Code exposes a hook system that allows you to run shell commands when certain events occur, for example:
- Claude is waiting for input
- Claude needs permission
When working in an IDE like PhpStorm, it’s easy to miss these prompts if you’re focused on another window. A desktop notification solves this perfectly.
Once implemented, it became obvious that the notifier itself didn’t care about Claude at all. It’s just a small macOS app that accepts arguments and posts a notification.
That makes it useful for any command-line workflow.
How it works (high level)
- A Swift app (
macos-notifier.app) - Uses
UNUserNotificationCenter(Apple’s modern notification API) - Requests permission on first run
- Posts notifications with the default system sound
- Supports optional image attachments
- Can optionally wait for a click and then activate a specified app
- Exits after delivery (or after click, if
--activateis used)
Because it’s a real app bundle:
- It appears in System Settings → Notifications
- Focus modes work as expected
- The notification icon is correct
Architecture choices
The app is structured into three files, each with a clear responsibility:
main.swift — Entry point and argument parsing
Uses Apple’s ArgumentParser library for command-line argument handling. This gives us:
- Automatic
--helpgeneration - Type-safe argument parsing
- Clear error messages for invalid input
@Option(name: .long, help: "The notification title")
var title: String
@Option(name: .long, help: "The notification content/body")
var content: String
@Option(name: .long, help: "Path to an image file to attach")
var image: String?
@Option(name: .long, help: "Bundle identifier of app to activate when clicked")
var activate: String?
AppDelegate.swift — Application lifecycle
Handles the app’s lifecycle using NSApplicationDelegate. This is important because:
- macOS notifications need a running application to be delivered reliably
- The delegate pattern lets us know when the notification is actually sent
- We can cleanly terminate the app after delivery
NotificationManager.swift — Notification logic
Handles all the notification-specific code:
- Requesting user permission
- Creating the notification content
- Attaching images (copied to a temp directory as required by macOS)
- Scheduling delivery via
UNUserNotificationCenter - Activating a target app when the notification is clicked (via
NSWorkspace)
This separation makes each piece easy to understand and modify independently.
Why use NSApplication?
A simpler approach would be to just send the notification and exit immediately. The problem is that notifications are delivered asynchronously — if the app exits too quickly, the notification might not appear at all.
Using NSApplication.shared.run() keeps the app alive until we explicitly terminate it. The UNUserNotificationCenterDelegate callbacks tell us exactly when the notification has been presented or interacted with, so we can exit cleanly.
This is the same pattern Apple’s own apps use.
Why an Xcode project?
You could compile a Swift notification tool with just swiftc from the command line. However, using an Xcode project provides several benefits:
- Asset catalogs — The app icon is managed properly with all required sizes
- Info.plist — App metadata like bundle identifier and permissions are in one place
- Dependencies — Swift Package Manager integration for ArgumentParser
- Debugging — Easy to set breakpoints and test with different arguments
The trade-off is that you need Xcode installed, but if you’re building macOS apps, you likely have it anyway.
Building the app
Requirements
- Xcode (any recent version)
- macOS 14.0 or later
Build and install
./scripts/install.sh
This will:
- Build the app in Release mode
- Copy it to
/Applications/ - Create a symlink at
/usr/local/bin/macos-notifier
The symlink approach
A macOS .app bundle is a directory. The real executable lives inside it:
/Applications/macos-notifier.app/Contents/MacOS/macos-notifier
You can call that full path from scripts, but it’s not exactly delightful.
The installer creates a symlink:
/usr/local/bin/macos-notifier -> /Applications/macos-notifier.app/Contents/MacOS/macos-notifier
This keeps the app bundle identity (icon + notification permissions) while giving you a short, predictable CLI command:
macos-notifier --title "Build" --content "Finished"
Using from the command line
# Basic notification (auto-dismisses after a few seconds)
macos-notifier --title "Build" --content "Build finished successfully"
# With an image attachment
macos-notifier --title "Screenshot" --content "Captured" --image ~/screenshot.png
# Persistent notification that activates an app when clicked
macos-notifier --title "Attention" --content "Click to return to PhpStorm" \
--activate com.jetbrains.PhpStorm
Common bundle identifiers:
- PhpStorm:
com.jetbrains.PhpStorm - PyCharm:
com.jetbrains.pycharm - VS Code:
com.microsoft.VSCode - Terminal:
com.apple.Terminal - iTerm:
com.googlecode.iterm2
To find an app’s bundle identifier:
osascript -e 'id of app "AppName"'
Using with Claude Code hooks
Here’s an example Claude Code hook configuration in ~/.claude/settings.json:
{
"hooks": {
"notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "macos-notifier --title 'Claude Code' --content 'Permission needed' --activate com.jetbrains.PhpStorm"
}
]
}
]
}
}
You can also include an image (like the Claude icon):
macos-notifier --title 'Claude Code' --content 'Permission needed' \
--image /Applications/Claude.app/Contents/Resources/TrayIconTemplate-Dark@3x.png \
--activate com.jetbrains.PhpStorm
Now whenever Claude needs your attention:
- A notification appears and stays visible until you click it
- Clicking brings PhpStorm (or your IDE) to the foreground
- You’re right back where you need to be
Making notifications persistent
By default, macOS shows notifications as Banners — they appear briefly and then slide away. For the --activate feature to be useful, you want notifications to stay visible until clicked.
To enable this:
- Open System Settings → Notifications
- Find macos-notifier in the list
- Change the notification style from Banners to Alerts
Alerts stay on screen until you explicitly dismiss or click them. This is exactly what you want for “Claude needs your attention” scenarios.
The app requests alert-style notifications in its Info.plist, but macOS gives users final control over notification behavior — which is the right design.
Why Swift?
I could have hacked something together with AppleScript or shell commands, but I wanted to actually understand how native macOS apps work. Swift turned out to be a good choice for learning:
- It’s already available on every macOS system with Xcode
- Apple’s notification APIs are designed for Swift and feel natural to use
- The resulting binary is small, fast, and dependency-free
- The code is readable even if you don’t write Swift every day
Using Claude Code as a guide made this approachable. Instead of spending hours reading documentation, I could describe what I wanted and iterate on the code while understanding each piece. The result is under 200 lines of actual code across three files — small enough to read top to bottom and actually understand.
For someone coming from other languages, this was a nice way to see how macOS apps are structured: the app delegate pattern, the notification center APIs, how asset catalogs work, and why you need an app bundle for certain system features.
Why this approach holds up
- Uses documented Apple APIs (
UNUserNotificationCenter) - No third-party runtime dependencies
- No background services
- Proper app bundle structure
- Clean separation of concerns
It’s boring in exactly the right ways.
Repository
The full source code lives here:
Native macOS notification CLI tool in Swift. Lightweight alternative to terminal-notifier.
Closing thoughts
This project is deliberately small.
It doesn’t try to be a framework, it doesn’t abstract itself into oblivion, and it doesn’t require you to learn anything beyond what macOS already ships with.
For me, this was also a learning exercise. I now understand how macOS app bundles work, why notifications need a running app, and how Swift’s delegate pattern fits together. Claude Code made that learning curve much less steep.
Sometimes the most useful tools are the ones that quietly do one thing well and then get out of the way. And sometimes building them yourself — even with AI assistance — teaches you more than any tutorial would.