Scripting with Roar¶
Roar is designed to be driven by other programs. This document captures the contract every consumer can rely on:
- Exit codes are stable and disjoint — branch on
$?. --waitstdout has a documented byte layout — read it programmatically, not visually.- Validation runs before any side effect — a
ValidationErrormeans nothing reachedusernoted.
If you change any of these, you'll break shell scripts in the wild. Treat them as the public ABI.
Exit codes¶
| Code | Meaning | Source |
|---|---|---|
0 |
Success. Notification posted; user clicked default or a custom action; list/clear/settings completed; at least one dismiss id matched. |
Send / List / Clear / Settings / Wait |
1 |
Authorization denied or a click-time side effect failed. | Send / ensureAuthorized / click handler |
2 |
--wait-timeout elapsed before the user interacted. |
Send --wait |
3 |
--wait mode and the user explicitly dismissed the notification (swipe, "X", right-click → close). |
Send --wait |
4 |
roar dismiss and none of the supplied identifiers matched any delivered or pending notification. |
Dismiss |
64 |
Invalid arguments (EX_USAGE). |
ArgumentParser |
All codes outside this table are bugs; please report them.
Dispatching on exit code¶
output=$(roar send --wait \
--title "Deploy?" \
--body "Push v3.2.0 to production?" \
--action ok:Approve --action no:Reject)
case $? in
0) action=$(printf '%s' "$output" | head -n1) # see --wait stdout below
;;
2) echo "no answer in time" ;;
3) echo "user dismissed" ;;
*) echo "unexpected exit" ;;
esac
In --wait, exit 0 covers both the default-click and any
custom-action click — distinguishing those needs stdout. Exit 3
covers the explicit-dismiss case alone, and exit 2 the timeout.
The --wait stdout protocol¶
When --wait is set, Roar blocks until one of: user interaction,
the --wait-timeout window elapses, or the process is signalled.
On normal completion it prints exactly the following bytes:
Default click (banner body itself)¶
Exit code 0.
Custom action button¶
Where <action-id> is the literal id from --action id:Title. Exit
code 0. Custom ids are screened at parse time to contain no
whitespace, newlines, or control characters — what you receive is
exactly what you typed.
Reply-style text action¶
Where <action-id> is the --text-action's id and <typed text>
is the user's reply verbatim. The reply can contain:
- Embedded newlines (the user pressed return mid-message)
- NUL bytes (paranoid input)
- Arbitrary UTF-8
So: read the trailing payload until EOF, not line-by-line. A
naive head -n 2 would truncate at the first newline in the typed
text. Exit code 0.
Explicit dismiss¶
Exit code 3. macOS's reverse-DNS
UNNotificationDismissActionIdentifier is collapsed to the short
sentinel before printing so your scripts don't have to track Apple's
naming.
Timeout¶
Exit code 2. No --wait-timeout flag → 5-minute default.
Reserved sentinel ids¶
default and dismiss are reserved — Roar rejects them at
parse time so custom-action stdout can't collide with the
default-click and explicit-dismiss sentinels:
--action id 'default' is reserved (it's used to signal the default
click / explicit dismissal in --wait mode). Choose a different id.
timeout is not currently rejected, but you should still
avoid it as an action id: a --action timeout:... click prints
timeout\n on stdout with exit code 0, while a --wait-timeout
expiry prints the same timeout\n with exit code 2. Branching on
stdout alone can't tell them apart — your shell case arm has to
check $? too.
Treat the three labels — default, dismiss, timeout — as
sentinels you never reuse as user ids.
JSON output (--json)¶
Every subcommand accepts a --json flag that swaps the text
format for a single JSON value on stdout. Exit codes are
unchanged across modes — scripts that already branch on $?
work identically. JSON mode is the right choice when:
- You'd rather not parse the text protocol's line layout (especially
for
--waitwith--text-action, where the typed text can contain newlines). - You're already piping through
jqfor downstream filtering. - You want a coarse outcome field (
click/dismiss/timeout) without having to write the if-else over$?first.
Per-subcommand schemas (each is stable scripting ABI — additions allowed, renames / removals are major-version breaks):
| Subcommand | Shape |
|---|---|
roar send (no --wait) |
{"identifier":"<id>","posted":true} |
roar send --wait |
{"outcome":"click"\|"dismiss"\|"timeout","action":"<id>","text":<string>\|null} |
roar list |
[{"bucket":"delivered"\|"pending","when":"<iso>"\|null,"identifier":"...","title":"...","body":"..."},...] |
roar dismiss |
{"requested":[...],"unknown":[...]} |
roar clear |
{"delivered_cleared":bool,"pending_cleared":bool,"categories_pruned":bool} |
roar settings |
{"authorization-status":"...","alert-setting":"...", …} |
Full field documentation for each shape lives on the corresponding reference page.
The JSON encoder uses sorted keys, no pretty-printing — output
is single-line, deterministic across runs. Optional fields are
always emitted as null rather than omitted, so jq '.text'
always returns a value (string or null) whether the user typed a
reply or not.
Switching on the JSON outcome¶
The --wait JSON shape's outcome field collapses the four
text-protocol cases into three coarse buckets. The exit code is
still set (0 / 2 / 3); pick whichever style fits the script:
# Branch on outcome via jq
r=$(roar send --wait --title "Deploy?" \
--action go:Go --action stop:Stop \
--wait-timeout 30s --json)
case "$(jq -r .outcome <<<"$r")" in
click)
action=$(jq -r .action <<<"$r")
echo "user picked $action"
;;
dismiss) echo "rejected" ;;
timeout) echo "no answer" ;;
esac
# Same logic via exit code (also works under --json)
roar send --wait --title "Deploy?" --json --wait-timeout 30s
case $? in
0) echo "click or button" ;;
2) echo "timeout" ;;
3) echo "dismiss" ;;
esac
Reading a --text-action reply cleanly¶
The text protocol has the well-known "read to EOF" caveat — embedded newlines in the user's typed reply would break a line-by-line read. JSON sidesteps it entirely:
result=$(roar send --wait --title "Note?" \
--text-action add:Add --json)
note=$(jq -r .text <<<"$result") # JSON-decoded, newlines intact
Parsing roar list without awk¶
# Identifiers of every delivered notification
roar list --delivered --json | jq -r '.[].identifier'
# Dismiss everything older than a cutoff (rough — JSON timestamps
# are strings; sort lexicographically as ISO 8601 allows)
cutoff="2026-05-15T00:00:00Z"
roar list --delivered --json \
| jq -r --arg cutoff "$cutoff" \
'.[] | select(.when < $cutoff) | .identifier' \
| xargs -r roar dismiss
Confirming a roar clear scope¶
roar clear is silent on stdout by default. Use --json to
confirm what was actually cleared (handy in CI logs):
roar clear --pending --json
# {"categories_pruned":false,"delivered_cleared":false,"pending_cleared":true}
Shell patterns¶
Capture an action id, branch on it¶
choice=$(roar send --wait \
--title "Deploy" \
--action approve:Approve \
--action reject:Reject::destructive)
ec=$?
case "$ec::$choice" in
0::approve) ./deploy.sh ;;
0::reject) echo "rejected by user" ;;
2::*) echo "no answer in time" ;;
3::*) echo "user dismissed" ;;
esac
Branch on $ec::$choice (not $choice alone) so the timeout
(exit 2, stdout timeout) and explicit-dismiss (exit 3, stdout
dismiss) cases are caught even if a user-defined action shares
one of those ids.
Branch on stdout AND exit code¶
output=$(roar send --wait \
--title "Quick log entry" \
--text-action save:Save \
--text-placeholder "what did you do today?")
ec=$?
case $ec in
0)
action=$(printf '%s' "$output" | head -n1)
text=$( printf '%s' "$output" | tail -n+2)
printf '%s\n' "$text" >> ~/journal.md
;;
2) echo "no entry today" ;;
3) echo "cancelled" ;;
*) echo "error (ec=$ec)"; exit "$ec" ;;
esac
Read multi-line replies safely¶
# Read the action id from the first line, then everything else as the
# message — preserving embedded newlines.
roar send --wait --text-action reply:Send | {
IFS= read -r action
text=$(cat)
printf 'action=%s\n' "$action"
printf 'text=<<EOF\n%sEOF\n' "$text"
}
Race a --wait against another event¶
--wait blocks the entire process. To race it against e.g. a
deploy completing externally, use a background process:
{ roar send --wait --title "Approve?" --body "..." --action ok:Approve > /tmp/choice.txt; \
echo $? > /tmp/choice.exit; } &
wait_pid=$!
while kill -0 "$wait_pid" 2>/dev/null; do
if [[ -f /tmp/deploy.done ]]; then
kill "$wait_pid" # send SIGTERM; exit code on signalled
# death is shell-defined (~128+signum),
# outside Roar's documented set — don't
# branch on it
break
fi
sleep 1
done
Python integration¶
import subprocess
import sys
result = subprocess.run(
[
"roar", "send", "--wait",
"--title", "Deploy?",
"--action", "ok:Approve",
"--action", "no:Reject",
"--wait-timeout", "1m",
],
capture_output=True,
text=True,
)
# Tail the protocol carefully.
match result.returncode:
case 0:
# action_id is everything before the first newline; remaining
# bytes (if any) are the text-action reply.
action_id, _, reply = result.stdout.partition("\n")
if action_id == "ok":
...
elif action_id == "no":
...
elif action_id == "default":
# User clicked the body, not a button.
...
case 2:
print("user did not respond in time")
case 3:
print("user dismissed the notification")
case 4:
# Not reachable for `send`, but documented here as a reminder
# that `roar dismiss` uses code 4 for the "no match" case.
...
case 64:
# ArgumentParser rejected the invocation — the stderr buffer
# has the diagnostic.
print("usage error:", result.stderr, file=sys.stderr)
CI integration¶
Don't block on a permission dialog¶
Roar requests provisional authorization on first run. macOS grants this silently, so a fresh CI runner won't wedge on a modal. The trade-off is that notifications post quietly to Notification Center (no banner / sound) until the user promotes the app — acceptable for an unattended runner.
If your CI workflow includes an interactive approval gate via
--wait, configure a sensible timeout:
- name: Manual approval
shell: bash
run: |
set -o pipefail
choice=$(roar send --wait --wait-timeout 5m \
--title "Approve release v${{ env.VERSION }}?" \
--action ok:Approve --action no:Reject)
ec=$?
case $ec in
0)
if [[ "$choice" == "ok" ]]; then
echo "approved=true" >> "$GITHUB_OUTPUT"
else
echo "approved=false" >> "$GITHUB_OUTPUT"
fi
;;
2) echo "approved=timeout" >> "$GITHUB_OUTPUT" ; exit 1 ;;
3) echo "approved=dismiss" >> "$GITHUB_OUTPUT" ; exit 1 ;;
*) exit "$ec" ;;
esac
Suppressing notifications in CI¶
You can't conditionally compile Roar out — but you can wrap it with a guard:
roar_send() {
[[ "${CI:-}" = "true" ]] && return 0
roar send "$@"
}
roar_send --body "Build finished"
Pitfalls¶
Don't parse stderr¶
stderr carries diagnostics that may include user-typed flag values, URLs, and arbitrary strings. The format is human-oriented and not contractual. Parse exit codes and stdout instead.
Don't depend on the order of action ids in --help¶
The --help output renders flags in the order they're declared. New
flags may be added between existing ones. If you script around
roar send --help, match by flag name, not position.
--wait keeps the process alive¶
A roar send --wait that's never clicked will run for the entire
--wait-timeout window (default 5 minutes). Don't background-spawn
hundreds of them — each holds an AppKit runloop and an XPC
connection to usernoted. Use scheduled triggers (--in / --at)
for fire-and-forget cases.
--identifier is the only path to in-place updates¶
Roar mints a fresh UUID for every send by default. To update a
banner instead of stacking a new one, you must pass --identifier
with the same value on subsequent sends.
Identifier length cap¶
Roar caps identifiers (and --thread-id, --target-content-id,
--filter-criteria) at 256 characters; longer values are
rejected at validation time. Rationale and the underlying
framework behaviour are in
Security → identifier length cap.
--at is local time unless you specify a zone¶
2026-12-31 17:00:00 is interpreted in the system's timezone at
parse time. If you share a script across timezones, prefer the ISO
8601 zoned form (...Z or ...+01:00).
Identifier collisions across roar send invocations¶
Two roar send --identifier build calls from different shells run
in a last-writer-wins race. The XPC bridge to usernoted
serialises individual requests in arrival order, so the second send
replaces the first. There's no locking primitive — if you depend on
serialisation, sequence the calls upstream.
A few worked examples¶
"Touch this banner to ack the page"¶
output=$(roar send --wait \
--title "Pager: $alert" \
--body "$details" \
--action ack:Ack \
--interruption-level time-sensitive \
--wait-timeout 15m)
case $? in
0) [[ "$output" == "ack" ]] && curl -X POST "$PAGER_ACK_URL" ;;
2|3)
# No ack — escalate.
curl -X POST "$ESCALATE_URL"
;;
esac
"Replace-in-place progress"¶
roar send --identifier dl --body "Downloading: queued"
while read -r progress; do
roar send --identifier dl --body "Downloading: $progress"
done < <(curl -fsSL https://api.example.com/progress | jq -r '.percent')
roar send --identifier dl --body "Download complete"
"Confirm before destructive op"¶
if [[ "$choice" != "yes" ]]; then
choice=$(roar send --wait \
--title "Confirm: rm -rf /opt/foo" \
--body "This will delete $files files. Proceed?" \
--action yes:Delete::destructive \
--action no:Cancel \
--wait-timeout 30s)
case "$choice"::$? in
yes::0) rm -rf /opt/foo ;;
no::0) echo "cancelled" ;;
*::2) echo "timed out, refusing to proceed" ; exit 1 ;;
*::3) echo "dismissed, refusing to proceed" ; exit 1 ;;
esac
fi
See also¶
COOKBOOK.md— task-oriented recipes.SECURITY.md— the threat model that motivates the validation / consent flags.TROUBLESHOOTING.md— diagnosing notification delivery and click-handler issues.man roar— the full reference page.