roar send¶
The workhorse. Posts a single notification, with optional click
handlers, scheduling, custom buttons, attachments, foreground-
presentation tuning, and a --wait mode that blocks until the
user interacts.
Flags are grouped by purpose below. --body is required (either
as a direct argument or piped on stdin); every other flag is
optional, with --title defaulting to the machine's user-visible
name.
Content¶
--title <string>¶
Notification title. Defaults to the machine's user-visible name from System Settings → General → About → Name, falling back to the short hostname when that lookup fails.
- Validation: rejects NUL bytes (would truncate at the XPC bridge); 4 KB UTF-8 byte cap. Newlines and tabs are accepted — macOS renders them as printable whitespace.
--subtitle <string>¶
A second line under the title in the banner / Notification
Center entry. Same validation as --title.
--body <string> / -b <string>¶
The main message body. If omitted and stdin is piped, the body is read from stdin (up to a 1 MB cap, per Security). 4 KB UTF-8 byte cap on the resolved value, whether sourced from the flag or stdin.
# direct
roar send --title "Build" --body "succeeded in 4m23s"
# stdin (omit --body; Roar reads piped input automatically)
git log -1 --pretty=%B | roar send --title "Last commit"
--body - is not a special stdin marker — it would set the
body to the literal string -. To use stdin, omit --body
entirely.
--sound <name>¶
A system sound name from /System/Library/Sounds/ or
/Library/Sounds/ (e.g. Glass, Hero, Submarine, Tink),
or the literal string default for the system's default
notification sound. Roar validates the name against an .aiff /
.caf file in those two directories before submitting.
~/Library/Sounds/ is not consulted: the
UNNotificationSound(named:) framework call doesn't resolve names
from the user's home directory, so including it would let
validation pass while the notification played silently. See
Troubleshooting → silent sound.
roar send --title "Done" --body "Build finished" --sound Glass
roar send --title "Heads up" --body "Tap to view" --sound default
Note: under provisional notification authorization (the default for first-run users), macOS silences sounds regardless of this flag. See Concepts → provisional auth.
--identifier <id>¶
Stable identifier for the notification. Reposting with the same identifier replaces the prior notification in place — no flicker, no duplicate in Notification Center, no second sound. Omit to mint a fresh UUID per send.
Useful for: build progress, status pings, transient state that should update rather than accumulate. See Cookbook → replace-in-place.
- Validation: non-empty after trimming, no control characters, max 256 chars.
roar send --identifier build-status --title "Build" --body "30%"
# ... later, same id:
roar send --identifier build-status --title "Build" --body "60%"
Click handlers¶
A click on the notification body triggers one of four click behaviours, depending on which flags were set at send time:
| Flag | What happens on click |
|---|---|
--activate-bundle-id |
The named app is brought to the foreground. |
--open-url |
The URL is opened (re-validated against the pinned scheme allow-list). |
--exec |
The shell command runs via /bin/sh -c in a scrubbed environment. |
--wait |
The action id is written to stdout and roar send exits. |
--wait is mutually exclusive with --activate-bundle-id,
--open-url, and --exec: in wait mode the calling script is
responsible for the side effect after roar exits, based on the
printed id.
The other three (--activate-bundle-id, --open-url, --exec)
are not mutually exclusive at send time, but combining them is
unusual. The click handler runs whatever's set in this order:
activate → open → exec. Each runs even if a previous one failed.
Typical use is to set exactly one; if you find yourself reaching
for two, consider whether --exec calling osascript/open is a
clearer expression of intent.
--activate-bundle-id <bundle-id>¶
Bring the named application to the foreground on click. The
value is a reverse-DNS bundle identifier like com.apple.Safari
or com.googlecode.iterm2 — find one with
osascript -e 'id of app "Safari"'.
- Validation: non-empty, no control characters; max 256 chars. The value is re-validated at click time so a spoofed notification can't sneak in a NUL.
- Failure mode: if the bundle id isn't installed,
urlForApplication(withBundleIdentifier:)returns nil and the click exits non-zero. Run withROAR_DEBUG=1to see which bundle id was rejected.
--open-url <url>¶
URL to open on click. By default only http, https, and
mailto are accepted; any other scheme requires an explicit
--allow-url-scheme opt-in.
- No "accept everything" override by design. Some schemes
carry significant click-time risk (
javascript:/data:run script;file:/afp:/smb:hand attacker-controllable paths to LaunchServices;applescript:/help:have a long history of RCEs). See Security → URL allow-list. - Credentials in URLs are rejected (
user:pass@host) — they would otherwise land in the notification's userInfo and in any error message. - Click-time re-parse: the send-time allow-list is serialised into userInfo and replayed at click time, so a click can never broaden what the send agreed to.
roar send --title "PR #42 ready" --body "Click to review" \
--open-url https://github.com/me/repo/pull/42
roar send --title "Email John" --body "Click to reply" \
--open-url 'mailto:john@example.com?subject=Hi'
--allow-url-scheme <scheme> (repeatable)¶
Add a URL scheme to the --open-url allow-list. Repeat for each
scheme. The default trio (http, https, mailto) is always
included.
roar send --title "Open in editor" --body "main.swift" \
--open-url 'vscode://file//Users/me/code/main.swift' \
--allow-url-scheme vscode
roar send --title "Join standup" --body "Tap to join" \
--open-url 'zoommtg://zoom.us/join?confno=12345' \
--allow-url-scheme zoommtg
Be deliberate. Adding javascript, data, applescript, or
file opts you into the click-to-script / click-to-RCE shape
of those handlers. The full risk catalogue is in
Security → URL allow-list.
--exec <command>¶
Shell command to run via /bin/sh -c on click. Requires
--allow-shell-on-click to avoid notifications like
"Build complete" silently carrying an unrelated payload.
- NUL-byte rejection: Send- and click-time. A NUL would
truncate at
posix_spawn'sstrdup, hiding part of the command from the user's visible value. See Security → control-char screening. - Environment: the spawned shell inherits a scrubbed
environment with
PATHpinned to the system default and the usual sensitive vars (BASH_ENV,LD_*,DYLD_*,IFS) filtered. See Security → environment scrubbing. - Working directory:
$HOME. The handlercds there beforeexec-ing the command (a workaround for a macOS-26 deprecation; see the comment inSources/ShellExecutor.swift). - Timeout: the child has 60 seconds to complete; after that
the handler kills the process group and exits non-zero. Click
handlers are meant for short follow-ups (open a file, kick off
a build); anything longer should detach (
nohup,at, a launchd job).
roar send --title "Build failed" --body "Click to rebuild" \
--exec 'cd ~/code && make' --allow-shell-on-click
--allow-shell-on-click¶
Opt-in flag required when --exec is set. Acknowledges the
click-to-shell-command surface. Without it, the validator
rejects --exec.
Scheduling¶
--in, --at, and --repeat are mutually exclusive: pick at
most one. Omit all three for immediate delivery. The validator
rejects sub-1-second windows and dates further than 365 days
out — both as typo guards
(Security → schedule bounds).
--in <duration>¶
Fire after a relative duration. Format: <number><unit> where
unit is s / m / h / d. Fractional values accepted
(1.5h = 90 minutes).
- Range: ≥ 1 second, ≤ 365 days.
- Grammar pinned: scientific notation (
1e5s), hex literals (0x1p3s), and leading+signs are rejected.
roar send --title "Stretch" --body "Step away from the keyboard" --in 30m
roar send --title "Standup" --body "Meeting starts now" --in 1.5h
--at <timestamp>¶
Fire at an absolute time. Accepted forms:
| Form | Example | Notes |
|---|---|---|
| ISO 8601 with zone | 2026-12-31T17:00:00Z |
unambiguous |
| ISO 8601 with offset | 2026-12-31T17:00:00+01:00 |
unambiguous |
| ISO 8601 + ms | 2026-12-31T17:00:00.123Z |
fractional seconds preserved |
| Local time, T | 2026-12-31T17:00:00 |
system's local zone |
| Local time, space | 2026-12-31 17:00:00 |
system's local zone |
| Local time, no secs | 2026-12-31 17:00 |
seconds default to 0 |
| Date only | 2026-12-31 |
midnight in the local zone |
- Validation: past timestamps rejected with a distinct error from near-future-but-too-close timestamps; both messages name the floor explicitly.
- DST + script portability: prefer the zone-explicit shapes in scripts you share across machines.
roar send --title "Lunch" --body "Step away" --at '2026-05-15 12:30'
roar send --title "Year-end" --body "Happy new year" --at 2026-12-31T23:59:00Z
--repeat <pattern>¶
Fire on a recurring calendar pattern. Accepted shapes:
| Pattern | Fires |
|---|---|
hourly |
At minute 0 of every hour |
daily:HH:MM |
Every day at HH:MM (24-hour, local) |
weekly:DAY:HH:MM |
Every week on DAY at HH:MM |
monthly:D:HH:MM |
Every month on day D at HH:MM |
DAY is one of mon|tue|wed|thu|fri|sat|sun (case-insensitive,
locale-stable). D is 1..31. monthly:31:* is accepted but
silently skips months with fewer than 31 days — UN's
documented behaviour.
roar send --title "Standup" --body "Meeting in 5 min" --repeat 'weekly:mon:09:00'
roar send --title "Rent due" --body "Pay before 10am" --repeat 'monthly:1:10:00'
Times are interpreted in TimeZone.current at fire time. The
weekday table is Gregorian-pinned, so users on non-Gregorian
system calendars still get the day they typed.
Interaction (--wait)¶
--wait¶
Block until the user interacts with the notification, then print the chosen action's id (and any typed text) to stdout and exit. The wait survives across the notification's full lifecycle — banner, Notification Center entry, click, custom button, or dismiss.
| User action | stdout | Exit code |
|---|---|---|
| Clicks the notification body | default\n |
0 |
| Clicks a custom button | <button-id>\n |
0 |
Clicks a --text-action and submits |
<button-id>\n<typed text>\n |
0 |
| Explicitly dismisses | dismiss\n |
3 |
--wait-timeout elapses |
timeout\n |
2 |
Mutually exclusive with --exec, --open-url, and
--activate-bundle-id (the calling script handles side
effects after roar exits, based on the printed id). The
typed text in the --text-action case may contain newlines
and arbitrary bytes — read to EOF, not line-by-line.
For the full stdout protocol and shell / Python / CI patterns, see Scripting.
choice=$(roar send --title "Deploy?" --body "Push v3.2.0 to prod?" \
--action approve:Approve \
--action reject:Reject::destructive \
--wait --wait-timeout 30s)
case "$choice" in
approve) ./deploy.sh ;;
reject) echo "user rejected" ;;
timeout) echo "no answer in 30s" ;;
esac
--wait-timeout <duration>¶
Maximum time --wait will block. Same grammar as --in
(30s, 5m, 2h, etc.). Defaults to 5m when --wait is
set, so unattended scripts can't hang forever. Max is 365 days.
On timeout, timeout\n is printed to stdout and the process
exits with code 2.
Custom actions (require --wait)¶
--action <id>:<title>[::<flags>] (repeatable, max 4)¶
Add a custom button to the notification. Syntax:
<id>:<title>::<comma-separated-flags>.
- id: opaque short string; printed on stdout when clicked.
- title: button label (free-form, screened for NUL).
- flags (optional, after
::): destructive— the title renders in red.auth-required— the user must unlock the device before the click is delivered.
The UN .foreground option is not exposed — roar is an
LSUIElement: true bundle with no window, so it would do
nothing.
Reserved ids: default and dismiss are rejected at parse
time because they collide with the
--wait stdout protocol's
default-click / explicit-dismiss sentinels. Avoid timeout too:
it isn't currently rejected, but a user-defined --action timeout:...
prints the same stdout line (timeout\n) as the --wait-timeout
expiry sentinel — the exit code disambiguates (0 for the click,
2 for the timeout), but shell scripts that branch on stdout
alone will conflate the two.
roar send --title "Delete this branch?" --body "feature/old-prototype" \
--action confirm:Delete::destructive,auth-required \
--action cancel:Cancel \
--wait
--text-action <id>:<title>[::<flags>] (at most one)¶
Add a text-input ("reply") action. Same syntax as --action,
same reserved-id list. Only one per notification.
On submit, roar prints <id>\n<typed-text>\n to stdout and
exits 0. The typed text is forwarded verbatim — newlines,
NUL bytes, multi-byte UTF-8 — so receivers should read to EOF
rather than line-by-line.
reply=$(roar send --title "Quick note?" --body "Capture today's thought" \
--text-action capture:Add \
--text-placeholder "Type your note..." \
--wait)
note=$(printf '%s' "$reply" | tail -n +2)
--text-placeholder <string>¶
Placeholder text shown inside the reply field. No effect
without --text-action. Default: empty.
--text-button-title <string>¶
Label of the inline submit button next to the reply field. No
effect without --text-action. Default: the system value
(typically "Send"). Pass "" to keep the system default.
Display behaviour¶
--interruption-level <level>¶
How disruptive the notification should be:
| Level | Behaviour |
|---|---|
passive |
Lands in Notification Center silently; no banner, no Focus break-through. |
active (default) |
Standard banner + NC behaviour. |
time-sensitive |
Attempts to bypass Focus / Do Not Disturb. Requires the user to allow time-sensitive in System Settings → Notifications → Roar. |
critical is intentionally not exposed — it requires a
paid Apple entitlement that an ad-hoc-signed CLI cannot claim.
See Cookbook → focus for how to wire time-sensitive notifications past Focus.
roar send --title "Coffee's ready" --body "Tap when you grab it" \
--interruption-level passive
roar send --title "Server alert" --body "CPU at 95% for 5 min" \
--interruption-level time-sensitive
--relevance-score <0.0-1.0>¶
Importance hint between 0.0 and 1.0. Higher values rank the
notification more prominently when Notification Center groups
collapse multiple entries into a summary. NaN / Infinity / out-of-
range values are rejected.
--foreground-presentation <option> (repeatable)¶
How the notification should appear if it arrives while roar
itself is still running in the foreground (typically only the
--wait flow — roar send without --wait exits within
~100ms of add(_:) returning). Repeatable; valid values:
| Value | Effect |
|---|---|
banner |
Show the banner |
list |
Add to Notification Center |
sound |
Play the configured sound |
When the flag is omitted entirely, roar uses
banner+list+sound. The literal value none is
rejected because an invisible-but-clickable notification is
a phishing vector — use --interruption-level passive instead
to suppress attention-grabbing presentation.
The setting has no effect on notifications delivered after
roar has exited — those go through the system's default
presentation routing.
Grouping & category metadata¶
--thread-id <id>¶
Group related notifications under a single banner stack. Same
validation as --identifier (non-empty, no control chars, 256
char max).
roar send --title "PR #1 ready" --body "feat/login flow" --thread-id pr-reviews
roar send --title "PR #2 ready" --body "fix/logout flow" --thread-id pr-reviews
--summary-format <format-string>¶
Format string for the summary line macOS renders when multiple notifications in this category collapse together. Tokens:
%u— unread count%@— comma-joined list of intent identifiers
roar send --title "New PR" --body "feat/billing" \
--thread-id pr-reviews \
--summary-format '%u new pull requests'
--target-content-id <id>¶
Hint string handed to the target app on click, used to route the
click to a specific window or document. Same validation as
--identifier. Mostly useful when combined with
--activate-bundle-id for apps that consume the hint via
UNNotificationContentExtension.
roar send --title "Reply to thread" --body "From: Alex" \
--activate-bundle-id com.apple.MobileSMS \
--target-content-id thread-abc123
Attachments¶
--attachment <path> (repeatable)¶
Local filesystem path to an image, audio file, or video. Local
paths only — --attachment does not fetch URLs; pre-download
remote content with curl -o /tmp/file URL and pass the
resulting path.
Repeat the flag for multiple attachments. macOS caps the visible
count per notification, but extras still ride along in the
post (e.g. for UNNotificationContentExtension to access).
Hardening: symbolic links at the leaf and at every
intermediate component are rejected (except for the
/tmp, /var, /etc system symlinks), realpath is run to
canonicalise the path, and the final .localPath is re-screened
for control characters. See
Security → attachment hardening.
roar send --title "Build result" --body "All tests passed" \
--attachment ~/builds/result.png
roar send --title "Photoshoot" --body "Three picks" \
--attachment shot1.jpg --attachment shot2.jpg --attachment shot3.jpg
--attachment-type-hint <uti>¶
A Uniform Type Identifier (e.g. public.png, public.mpeg-4,
public.jpeg) telling UN how to interpret the attachment. Use
when the file's extension is misleading or absent — the
framework otherwise infers type from the filename. Applies
uniformly to every --attachment in the invocation.
roar send --title "Capture" --body "Screenshot ready" \
--attachment /tmp/screenshot-no-extension \
--attachment-type-hint public.png
--no-thumbnail¶
Suppress the small thumbnail preview macOS renders alongside the attachment in Notification Center. The full attachment is still delivered; only the preview glyph is hidden.
--thumbnail-time <seconds>¶
For video attachments only: the seconds-offset into the video to use for thumbnail generation. Non-negative finite double. Ignored for image attachments.
roar send --title "Build demo" --body "Tap to play" \
--attachment ~/builds/demo.mp4 \
--thumbnail-time 3.5
Lock-screen privacy¶
The user controls a global "Show Previews" setting in System Settings → Notifications: Always, When Unlocked, or Never. The flags below shape what appears on a locked screen under the latter two settings, letting sensitive content (auth codes, private messages) post safely without leaking on the lock screen.
--hide-previews-body-placeholder <string>¶
Placeholder body shown when previews are hidden. The real body is delivered normally and visible after unlock; the placeholder is what shows on the lock screen.
--show-title-when-previews-hidden¶
Boolean flag. Show the title on the lock screen even when
previews are hidden. Without this flag, macOS replaces the
title with the bundle name (Roar) when previews are off.
--show-subtitle-when-previews-hidden¶
Boolean flag. Show the subtitle on the lock screen even when previews are hidden. Without this flag, macOS suppresses the subtitle entirely under previews-hidden.
Focus filtering¶
--filter-criteria <string>¶
Hint handed to the framework via
UNMutableNotificationContent.filterCriteria. Used by Focus
filters (configured per-user in System Settings → Focus) to
decide whether a notification breaks through the current Focus.
Free-form within the same envelope as the other XPC routing keys: non-empty after trimming, no control characters, max 256 characters. UN itself does no further validation — the value is matched verbatim against whatever Focus filter the user has configured. Common shapes are conversation identifiers (for chat apps), priority labels, or a domain string the user has matched in their Focus filter rules.
JSON output (--json)¶
Pass --json to emit a JSON object on stdout instead of text /
silence. Two shapes, depending on whether --wait is in play.
Non-wait¶
The text path is silent on success. With --json, roar send
prints a single object confirming the post:
| Field | Type | Notes |
|---|---|---|
identifier |
string | The value you passed via --identifier, or the UUID Roar minted if you didn't. Useful for downstream roar dismiss "$id" or replace-in-place reposts. |
posted |
boolean | Always true on success — failures throw and exit non-zero, the JSON shape never carries "posted": false. |
# Capture the minted identifier for a follow-up dismiss
id=$(roar send --title "Build…" --body "running" --json | jq -r .identifier)
# later:
roar dismiss "$id"
--wait¶
A single JSON object representing the terminal outcome. Replaces
the line-oriented text protocol (<action>\n or
<action>\n<text>\n).
| Field | Type | Notes |
|---|---|---|
outcome |
"click" | "dismiss" | "timeout" |
Coarse-grained verdict. Most scripts that previously switched on the text protocol's four shapes can case on this alone. |
action |
string | The action identifier. Reserved sentinels: default (body click), dismiss (explicit dismiss), timeout (timeout fired). Custom --action ids pass through verbatim. |
text |
string | null |
Typed text for a --text-action submission; null for every other outcome. JSON string-escaping handles embedded newlines and arbitrary bytes — the text protocol's "read to EOF" workaround isn't needed. |
Exit codes are unchanged across output modes:
waitDismissExitCode (3), waitTimeoutExitCode (2), 0 on
matched click. Scripts using $? work identically; scripts
using jq -r '.outcome' get a single token to switch on.
The schema is stable scripting ABI — fields may be added, but
renames or removals are major-version breaks. text always
appears as either a string or null, never omitted, so
jq '.text' always returns a value.
# Switch on the coarse outcome
r=$(roar send --title "Deploy?" --action go:Go --action stop:Stop \
--wait --wait-timeout 30s --json)
case "$(jq -r '.outcome' <<<"$r")" in
click)
action=$(jq -r '.action' <<<"$r")
echo "user picked $action"
;;
dismiss) echo "user said no" ;;
timeout) echo "no answer in 30s" ;;
esac
# Pull the typed text out of a --text-action reply
reply=$(roar send --title "Note?" --text-action note:Add --wait --json)
note=$(jq -r '.text' <<<"$reply")