---
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:

```bash
# 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:

```bash
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:

```json
"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)
```bash
adom-desktop notify_user '{"title":"Export done","body":"gerbers.zip is ready."}'
```

**2. Level** (carried in the log/urgency)
```bash
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)
```bash
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.
```bash
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
```bash
# 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`**
```bash
# 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`**
```bash
# 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)
```bash
# 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)
```bash
# 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:

```json
{
  "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.
