The watcher alerts you when an attempt ends, blocks, hits a usage limit or goes quiet (a repeated turn end, attempt.idle, does not alert; a block counts only once a second observation still reads blocked, the next status event or reconcile tick, so a one-poll flicker does not alert, and a failed agent.explain keeps the block pending for the next tick; it alerts on the first observation instead when Hand noted a launch problem (a trust screen it did not answer, a prompt it could not submit, a copilot that fell back to Auto or is not signed in), when the supervisor asks a decision (t3 decision: Keep the old cookie name?), once when the supervisor has idled 60 minutes with unwatched work (When the supervisor idles), and once per supervisor session when an opencode supervisor did not take its resume note (supervisor.blocked). It also alerts once per supervisor when a supervisor over its context limit writes no handoff note, or Hand cannot start its fresh session (supervisor.rollover, When the context grows). By default that is a desktop notification. On Linux it offers Open: a click opens the item in the desktop window when hand-desktop is installed (next to hand, else on PATH): it runs hand-desktop --open handboard://open/<fleet id>/…, the open link of the alert's ref, and the window shows that fleet and item, or the fleet's two panes for a supervisor alert. A window that already runs takes the link and comes forward, since hand-desktop is a single instance per user; a refused or unknown link shows its message in the window. Without hand-desktop, or when it cannot start or exits with an error within 10 seconds (a first launch refuses a fleet it does not know), the click opens the item's board page in the browser, signed in when that browser holds the 400-day login. Hand keeps up to 8 of those notifications waiting for a click, each for at most 10 minutes; when 8 already wait, when notify-send has no --action and --wait (checked once with notify-send --help), when the click command fails without printing the action, or when board.addr is not a board address (a loopback host with an explicit port; no login, query or fragment), it sends the plain notification without Open. To send every alert to your own command instead, put its argv in routing.json. This sh example is for Linux and macOS:
"notify": ["sh", "-c", "exec curl -s -H \"Title: $HAND_NOTIFY_REF\" -H \"Click: ${HAND_NOTIFY_APP_URL:-$HAND_NOTIFY_URL}\" --data-raw \"$HAND_NOTIFY_TEXT\" https://ntfy.sh/CHOOSE-A-LONG-RANDOM-TOPIC"]Install the ntfy app on your phone and subscribe to that topic. Hand runs the command without a shell, with HAND_NOTIFY_KIND (decision.asked, attempt.blocked, attempt.quiet, attempt.limited, attempt.exited, attempt.stopped, attempt.failed, attempt.interrupted, attempt.long, supervisor.blocked, supervisor.limited, supervisor.stalled, supervisor.rollover), HAND_NOTIFY_REF (d3, a7, supervisor), HAND_NOTIFY_TEXT, HAND_NOTIFY_URL and HAND_NOTIFY_APP_URL set. HAND_NOTIFY_APP_URL is handboard://open/<fleet id>/decision/d3: the link that opens the item in the Hand app on your phone, which pairs over iroh (Alerts that open the app, The open link). It holds no token, so the example's Click header sends it, and falls back to HAND_NOTIFY_URL for a Hand that does not set it. It is empty only for a ref Hand cannot place. HAND_NOTIFY_URL is the item's board page on the laptop, for example http://127.0.0.1:7777/<fleet id>/decision/d3, which a phone cannot open. It is empty when no board runs or board.addr is not a loopback board address. If a tap on the ntfy alert does not open the app on your phone, send the link as a button instead: -H "Actions: view, Open in Hand, $HAND_NOTIFY_APP_URL". The command gets 10 seconds, and a failure is ignored. When the timeout expires Hand kills the command; on Linux and macOS it kills the whole process group, but on Windows only the command itself, so a child of sh -c can outlive the kill. Prefer an argv that runs the program directly, or exec inside sh -c. A routing.json that cannot be read leaves the desktop notifier in use (on Windows there is none, so no alert is sent), and the watcher writes one line about it to its own output, watch.log when it runs detached, which no one is shown. --notify=false turns all alerts off.
The notify command sends the URL, the title and the text to wherever you point it. With the ntfy.sh recipe above, ntfy.sh receives them in the clear, so it sees the fleet id, the item's ref and the alert text, and no name of your machine. The text is yours to keep short: use a long random topic, or a ntfy server of your own.
On Windows, this toast recipe uses powershell.exe (unverified: it has not been run on a Windows machine). It borrows Windows PowerShell's app ID, because Windows may show no toast for an app ID that nothing registered. Hand starts it without a console window:
"notify": ["powershell.exe", "-NoProfile", "-Command", "[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]; $x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent('ToastText02'); $t = $x.GetElementsByTagName('text'); [void]$t.Item(0).AppendChild($x.CreateTextNode($env:HAND_NOTIFY_REF)); [void]$t.Item(1).AppendChild($x.CreateTextNode($env:HAND_NOTIFY_TEXT)); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($x))"]The alert feed
The watcher also writes every alert it sends to notify into the alert table of hand.db, in the same place in the code, so the feed holds exactly what notify gets: the same kinds, the same text (control characters replaced by spaces, cut to 200 characters), and nothing for attempt.idle. --notify=false stops the feed along with the notifications. The notify command and routing.json are untouched, and ntfy keeps working beside the feed. Each row also holds a handboard://open/<fleet id>/… link to the item, which is the same page the HAND_NOTIFY_URL names. Hand keeps 7 days of rows and deletes older ones when it records the next alert.
The Hand app reads the feed with GET /<fleet id>/alerts/feed?after=<seq>&wait=25 over its iroh connection, signed in as a paired phone: the contract has the request, the answer and test vectors. The Board holds an empty request for 25 seconds at most, and the request of a phone that was revoked meanwhile fails with 403.
The Board lists the week at /<fleet id>/alerts (linked from the Phone page): one row per alert with when it was raised, when the app fetched it and when the app acknowledged it, and the delay between raising and acknowledging. A phone acknowledges an alert by asking for the next one with a cursor at or past it. The header shows the total, how many are not acknowledged, how many took over a minute, and the median, the 95th percentile (nearest rank) and the longest delay. The receipts belong to the fleet, not to a phone. The table is the truth for a trial of the app against ntfy: it carries the laptop's clock only.