name: adom-desktop-notify description: >- Send native macOS Notification Center notifications from a cloud AI or a bridge via Adom Bridge's notify_user verb, and GET THE USER'S ANSWER BACK. Two separate axes: (1) get the reply to you (wait:true blocks and returns their choice, or poll notify_events/notify_response; there is no server-push to a turn-based AI). (2) reveal a window on their screen with focus, a local desktop action, including focus:{host:true} to reveal Adom Hydrogen when Bridge is embedded, and focus:{app} for any app. Use to alert the user (build done, export ready), point them at a window that needs them, or ask for a decision and read it back. Covers the notification types, the exact verb calls, how the AI polls or waits for responses, and what a click actually does.

Notifications: notify_user (macOS Notification Center)

notify_user fires a native macOS notification under Adom Bridge's own identity (UNUserNotificationCenter via the notification plugin, with an osascript fallback if the plugin is unavailable). It lands as a banner and in Notification Center like any first-class app.

Reachable three ways, all identical shape:

  • CLI / cloud: adom-desktop notify_user '{"title":"...","body":"..."}'
  • Direct API (sibling app): POST /command {"app":"desktop","command":"notify_user","args":{...}}
  • From a bridge: the same notify_user verb via the relay.

Returns { "status":"ok", "action":"displayed", "id":"...", "clickBehavior":"...", "_hint":"...", "_next":[...] } plus _couldHaveDoneBetter and _capabilities when the call left something on the table. Read those; they are how you learn what this verb can do without reading this file.

TWO axes: do not confuse them

A notification can do two separate things. Almost all confusion comes from mixing them up:

Axis Field(s) What it does Reply to you?
1: GET THE USER'S ANSWER BACK wait:true (block-and-return), or poll notify_events / notify_response delivers the user's click/choice to you, the AI YES, this is the one you usually want
2: REVEAL A WINDOW focus:{...} raises a window on the user's SCREEN (Hydrogen, a browser, a dialog) NO, pure local desktop action

Axis 1: how you get the answer back (there is NO server-push; you poll or you wait)

You are turn-based; Bridge cannot inject an event into your conversation. So there are exactly two ways, fastest first:

# FAST PATH: wait:true. The call BLOCKS and returns the user's choice inline.
adom-desktop notify_user '{"title":"Ship v2 to prod?","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}],"wait":true,"timeoutSec":120}'
#   -> { "pending": false, "action": "Ship it" }

# FIRE-AND-CONTINUE: do not block; drain events on a later turn.
adom-desktop notify_user '{"id":"deploy-42","title":"Ship v2?","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}]}'
adom-desktop notify_events '{"sinceSeq":0}'   # every click since a cursor (recommended: no per-id polling)
adom-desktop notify_response '{"id":"deploy-42"}'   # or poll one id

The click is recorded durably (a cross-process file), so both paths work even when the click is handled by a transient process. Details: sections 5 / 5b below.

Axis 2: focus (reveal a window) is NOT a reply

focus is covered next. It raises a window on screen; it does not tell you anything by itself. A notification can do BOTH at once (a reminder with buttons plus focus:{host:true} reveals Hydrogen on click AND reports the click back via wait / notify_events).

Start here: make the click DO something (focus)

Most notifications are about something else on screen: a browser waiting on a login, a dialog, an app that needs a click. If you send a bare notification, clicking it opens the Adom Bridge window, which is almost never what the user wanted. They clicked because they wanted to get to the thing.

Pass focus and one click takes them straight there:

adom-desktop notify_user '{
  "title": "Sign-in needed",
  "body": "Google wants you to confirm before I can continue.",
  "focus": { "titleContains": "Sign in" }
}'

Ways to name the window, in order of preference:

selector when note
{"hwnd": 459604} best: you already have the handle exact. On macOS this is the CGWindowList window id; get it from desktop_list_windows, or from whatever verb opened the window.
{"cacheKey": "my-session-id"} you drove the window through a verb that cached it under a key survives title churn, which a title match does not.
{"app": "adom-hydrogen"} you know the owning APP, not the exact window process/app name match, case-insensitive. Survives PID churn (a process that restarts keeps its name), so it beats a stale window id for a long-lived app that respawns.
{"host": true} the notification is a prompt/approval Bridge is showing on behalf of Adom Hydrogen "the app Bridge is embedded under." Bridge resolves Hydrogen's window itself; you do not need its id or title. A click reveals Hydrogen, not Bridge. Falls back to opening Bridge when standalone.
{"titleContains": "Sign in"} you only know roughly what it says substring, case-insensitive. Breaks the moment the title changes, and page titles change constantly.

Bridge resolves the target when you SEND and tells you in the response whether the click will land:

"clickBehavior": "focus-window",
"focus": { "resolved": true, "hwnd": 1313916, "title": "Sign in", "resolvedBy": "title" }

resolved:false means no matching window exists right now. The notification still shows, and Bridge re-resolves at click time using the selectors you passed, so this is fine if the window has not been created yet, and a bug you should fix if it should already exist. If it still cannot find the window on click, it falls back to opening Adom Bridge rather than doing nothing.

Rule of thumb: if your notification mentions a window, a browser, a dialog, a tab, or asks the user to go click something, it should carry focus.

Fields

field type notes
title string bold top line. Optional (missing/null renders as empty).
body string message text (use \n for a second line). Optional.
level string info (default), success, warning, error, emergency. macOS owns the notification chrome; level is carried for logging/urgency and does not restyle the banner.
durationLong bool request a longer display duration. macOS banners are transient by default; how long a banner stays is ultimately a macOS setting (banner vs alert style).
scenario string reminder / alarm / incomingCall mark the notification as needing acknowledgement. On macOS a banner still auto-dismisses unless the user has set Adom Bridge's notification style to Alerts; pair with buttons and wait/notify_events so you learn the outcome either way.
buttons array [{ "label": "...", "arguments": "<optional uri>" }], action buttons. Omit arguments and Bridge synthesizes a round-trip URI so a click is reported via notify_response. Buttons only render if the user has ALLOWED notifications for Adom Bridge (System Settings > Notifications).
actions array back-compat: ["Label1","Label2"], simple label-only buttons (same round-trip as buttons without arguments).
progress object { "title":"...", "value":0.0-1.0, "status":"...", "valueOverride":"6 / 10" }. Accepted cross-platform; macOS Notification Center has no progress-bar chrome, so treat this as status text on macOS.
inputs array [{ "id":"reply", "type":"text", "placeholder":"..." }]. Display only; see Limitations. For a real choice round-trip use buttons.
id string correlation id. Supply it so you can poll notify_response {id} for the clicked button. Auto-generated if omitted (and returned to you as id, so you can still poll it).
focus object {hwnd} | {cacheKey} | {titleContains} | {app} | {host:true}: the window this notification is ABOUT. A body click brings THAT window to the front instead of opening Bridge. See the section above.
wait bool block the verb until the user clicks (or timeoutSec elapses) and return their choice, instead of polling notify_response. Needs buttons (or a focus click) to be meaningful.
timeoutSec number how long wait blocks, 1-600. Default 60.

The types: copy/paste

1. Basic (transient banner)

adom-desktop notify_user '{"title":"Export done","body":"gerbers.zip is ready."}'

2. Level (carried in the log/urgency)

adom-desktop notify_user '{"title":"DRC failed","body":"3 clearance violations.","level":"error"}'

3. Acknowledge-me reminder (pair with buttons and read the answer back)

adom-desktop notify_user '{"title":"Review needed","body":"The board is ready for your sign-off.","scenario":"reminder","buttons":[{"label":"Acknowledge"}]}'

4. Progress updates: re-fire with the SAME id as the job advances.

adom-desktop notify_user '{"id":"export-1","title":"Exporting gerbers","progress":{"title":"InstaPCB","value":0.6,"status":"Working...","valueOverride":"6 / 10 layers"}}'

5. Buttons that round-trip: learn which one the user clicked

# Fire with a correlation id you choose:
adom-desktop notify_user '{"id":"approve-42","title":"Deploy v2?","body":"Ship to prod?","scenario":"reminder","buttons":[{"label":"Ship it"},{"label":"Hold"}]}'

# Then poll until the user acts (pending:true until they click):
adom-desktop notify_response '{"id":"approve-42"}'
#   pending -> { "pending": true,  "action": null }
#   clicked -> { "pending": false, "action": "Ship it" }

Poll every 1-2s; give up after a sensible timeout (the user may dismiss it).

The click is recorded durably (a cross-process file), so notify_response works even when the activation is handled by a transient process, not just when Bridge's window is open.

5b. Get told without per-id polling: notify_events

# One call returns EVERY click since your cursor (the relay has no true
# server-push, so this is the "tell me immediately" path):
adom-desktop notify_events '{"sinceSeq":0}'
#   -> { "events": [ { "event":"notify_clicked", "id":"approve-42", "action":"Ship it", "at":"..." } ],
#        "nextSeq": 1, "count": 1 }
# Pass the returned nextSeq as sinceSeq next time to get only new clicks.

5c. Retract a delivered notification: notify_dismiss

# Clear a notification from Notification Center once you have handled it,
# by the same correlation id you fired it with (best-effort):
adom-desktop notify_dismiss '{"id":"approve-42"}'   # -> { "dismissed": true }

6. Explicit button URI: drive an arbitrary Bridge action (advanced)

# A button can carry any adom-desktop:// URI; clicking dispatches it.
adom-desktop notify_user '{"title":"...","buttons":[{"label":"Approve 1 hr","arguments":"adom-desktop://shell-approve?dur=3600"}]}'

7. Point the user at the window that needs them (the most useful type)

# The whole notification is "go look at this", so make the click go there.
HWND=$(adom-desktop desktop_list_windows | jq '.windows[]|select(.title|test("Sign in"))|.hwnd')
adom-desktop notify_user '{"title":"Sign-in needed","body":"Confirm in the browser and I will continue.","focus":{"hwnd":'"$HWND"'}}'

Add scenario:"reminder" plus a button if you also need to know they dealt with it. A focus click is itself recorded as an acknowledgement (action:"focus"), so notify_response / notify_events tell you they engaged even with no buttons at all.

Click behaviour: what actually happens

Clicking the notification body or any button dispatches Bridge's adom-desktop: URI protocol through the single-instance handler.

What a body click does depends on you:

you sent body click does
focus: {...} brings that window to the front. Falls back to opening Bridge if the window is gone. Also records action:"focus" under your id.
nothing opens the Adom Bridge window. Rarely what the user wanted.

A button click does what its arguments URI says, defaulting to a round-trip that reports the label back through notify_response. Buttons do not foreground Bridge; a notification exists to reach someone WITHOUT interrupting them, so acknowledging one must not yank them out of what they were typing. The exceptions are deliberate: a body click (the conventional "show me the app" gesture) and a shell-approval grant (you should see what you just authorized).

What a click can and cannot be wired to

focus-window is exposed as a click action; arbitrary verbs are not, and will not be. The reason is that you write both the button label and its URI, so a button could always be mislabelled. Only actions whose worst case is tolerable under ANY label belong on a notification: raising a window is non-destructive, immediately visible, and undone by one Cmd-Tab. Anything that executes, writes, or grants is not; that is what the permission model is for.

The response teaches you

Every notify_user response carries what a click will do and what to do next:

{
  "status": "ok", "action": "displayed", "id": "t-42",
  "clickBehavior": "focus-window",
  "focus": { "resolved": true, "hwnd": 1313916, "title": "...", "resolvedBy": "hwnd" },
  "_hint": "Shown. Clicking the notification brings \"...\" to the front ...",
  "_next": [ "notify_response {\"id\":\"t-42\"}", "notify_events {\"sinceSeq\":N}", "desktop_list_windows" ]
}

When the call left something on the table you also get _couldHaveDoneBetter (specific, e.g. "no focus was passed, so clicking this just opens Adom Bridge") and _capabilities (the full menu of what notify_user can do). These appear ONLY when there is something to fix, so a well-formed call gets a short response.

Current coaching triggers:

  • no focus (with an extra nudge when the wording sounds like it is asking the user to go do something)
  • focus that resolves to no window right now
  • buttons without scenario:"reminder" (a transient banner the user may never see)
  • wait:true with nothing to click

Limitations (macOS Notification Center rules, not Bridge bugs)

  • Notifications must be allowed. The first notification triggers the macOS permission prompt; if the user declined, nothing shows. Fix in System Settings > Notifications > Adom Bridge.
  • Banners are transient by default. macOS decides banner-vs-alert style per app; only the Alerts style keeps a notification on screen until acted on. A scenario:"reminder" cannot force persistence past the user's banner-style setting, and Focus / Do Not Disturb can suppress banners entirely (they still land in Notification Center).
  • Buttons require notification permission and appear on hover/expand of the banner, per macOS convention.
  • Typed inputs are display-only. For a real choice round-trip, use buttons (each choice is a button) and read notify_response.
  • A notification cannot truly block. For a hard, must-click-to-proceed prompt, that is a real dialog window (the Bridge approval dialog), not a notification.

Troubleshooting: "I got status:ok but saw no notification"

notify_user returning status:ok means only that Bridge accepted the request and dispatched the notification; it does not confirm it displayed. If nothing appeared:

  1. Check Notification Center (click the clock in the menu bar). Focus / Do Not Disturb routes banners straight there. That is a macOS setting, not a Bridge bug; the user can allow Adom Bridge notifications during Focus in System Settings > Focus.
  2. Check notification permission: System Settings > Notifications > Adom Bridge must be allowed.
  3. Check you are actually reaching that Bridge. From a cloud container, notify_user traverses the relay the Bridge is connected to; a --target <name> aimed at a relay that is not running silently fails, so confirm with adom-desktop targets first. (The direct API does carry notify; a bridge or sibling app on the same machine can POST /command {"command":"notify_user",...} with no relay.)
  4. When you must be SURE the human saw it, do not rely on the notification alone; also surface the message in your chat/response.

When to use which

  • Awareness ("export done", "build green"): basic, or level:"success".
  • "Go look at this" (a login, a dialog, an app waiting on them): focus, so the click lands on that window. This is the single highest-value field in the verb and the one most often forgotten.
  • Need the user to choose: buttons plus wait:true (or poll notify_response / notify_events).
  • Long-running job: re-fire with the same id as status advances.
  • Genuine emergency: level:"emergency", and consider also summoning a window via focus so it cannot be missed.