Push notifications

Overview

Browser notifications let mouaif follow a running chat when the tab is in the background or closed. The important alerts are interactive: a user can answer a simple two-choice ask_user question, allow a tool once, or deny a tool directly from the notification when the browser supports actions.

Stack: web-push (server), service worker push event (client), VAPID keys (auto-generated, stored in SQLite).

Usage

Enabling push notifications

  1. Open Settings → Notifications.

  2. Tap Enable under "Web notifications".

  3. Accept the browser's permission prompt.

  4. Optionally tap Send test notification.

    The same screen exposes the notification types: ASCII chat status, Authorization, Authorization actions, and Sign-in alerts. Authorization quick actions can be toggled separately. Each preference uses a short summary so the rows remain compact on a phone. iPhone and iPad require the app to be installed on the Home Screen before Web Push can be enabled.

    The Server configuration group shows the origin used by the notification service, the generated-key status, and the VAPID contact. Opening this screen repairs a missing VAPID pair automatically; there are no keys to copy into the browser. Configuration values and notification-type descriptions wrap on narrow screens so their full contents remain readable.

Serving from a public domain

Web Push on iPhone and iPad requires an installed Home Screen app served over HTTPS. When a TLS reverse proxy exposes mouaif on a public domain, start the HTTP server with its canonical external origin:

mouaif serve --host 127.0.0.1 --public-origin https://mouaif.example.com

MOUAIF_PUBLIC_ORIGIN=https://mouaif.example.com is the equivalent environment setting. The origin must contain only the scheme, host, and optional port. This explicit value is used for same-origin checks, the Secure browser-session cookie, subscription records, and the VAPID contact; untrusted forwarding headers are not accepted as configuration.

What triggers a push

EventWhenContent
ask_user_requiredThe model pauses for a structured answerQuestion; exactly two single-choice answers can be tapped directly
authorization_requiredA tool is waiting for approvalTool name with Allow once and Deny actions
doneA chat turn completes successfullyChat title + "Response complete"
errorA chat turn fails (stream error, upstream error)Chat title + error message
loginA password, setup, or passkey sign-in issues a new session"New sign-in as <user> from <platform>"

Only two per-chat notification slots are used. Authorization alerts (ask_user_required and authorization_required) share chat-{chatId}-attention; all other chat status (progress_update, done, and error) shares chat-{chatId}-status and the same ASCII format. New notifications replace the older notification in their slot, so statuses cannot stack and a question cannot be replaced by ordinary status. The service worker suppresses an alert only when the same chat ID is confirmed visible on screen by a fresh page response, regardless of query-parameter order or whether the open route includes projectDir. A page that is focused but hidden — screen locked, another app on top, or an iOS PWA sitting in the background — still delivers the notification. Suppression also clears queued alerts for that chat because their content is already visible.

Because WindowClient.focused / visibilityState are unreliable on some engines (Safari, iOS PWA), the page is the authority on its own visibility. The service worker queries every live window when each push arrives, and only a current { hash, visible, focused } response can suppress it; no cached visibility table is maintained. Responses normally use a transferred MessagePort; the page also mirrors each response as a query-ID-correlated service-worker message because some iOS WebKit releases deliver Client.postMessage but silently drop the transferred port. A suspended or recently closed PWA may remain temporarily listed as a focused client but cannot answer; in that case the alert is shown rather than discarded. Fresh responses are matched by parsed chat ID.

On iOS Safari, the OS can keep replaced notifications visible until the user acts on them. Before showing a push, the service worker closes the matching tag. Status pushes also close legacy progress/completion/error tags carrying the same chat ID, preventing older notification formats from stacking beside the current status slot.

Clicking a notification

Subscription sync

Startup sync compares the browser's current Push endpoint with the server's saved subscriptions for the current session. After a server restart, an existing local subscription is rebound to the fresh session instead of being destroyed. Disabling notifications removes only an endpoint owned by that session.

Configuration

The app-level notifications setting stores:

{
"status": true,
"authorization": true,
"quickActions": true,
"login": true
}

Browser permission and subscription are installation-specific. Event preferences are app-wide and are exposed through the normal /api/settings/app endpoint.

Progress notifications

The model's report_progress calls and task updates (update_progress / complete) emit progress_update stream events. Each one sends a push tagged chat-{chatId}-status, so the OS replaces the previous status notification for that chat instead of stacking notifications. Every status uses a true ASCII bar and is gated by the ASCII chat status toggle (notifications.status), which defaults to on.

The bar is adapted to the device that receives it: every phone size, the notification style the OS presents, and the OS version. When a browser subscribes it reports its own facts — the measured width of one notification body line, its viewport, its platform and version — and the server stores them per device and builds each body for that device.

Width follows the screen, continuously. The number of cells is derived from the measured body line, not from size buckets, so a 320 px phone and a 430 px phone get a different bar, and a desktop window a longer one.

Style decides the layout. A collapsed Android notification shows one body line; an iOS banner (15+) shows two; a desktop toast about two. When the platform previews a single line, the bar and the most important fact share that row — [###---] 40% · Refactoring... — because a second row would never be seen. When it previews two or more, the bar takes its own row and the facts follow. An empty fact set leaves the bar alone in both layouts.

The percentage never leaves the bar. The gap between ] and 40% is a non-breaking space, so the pair is one unbreakable token. The OS renders a notification body in its own font — larger and proportional, unlike the monospace the line width was measured with — and would otherwise be free to wrap between the bar and its number, leaving an unlabelled bar on one line and a stray 40% on the next. The whole [####----] 40% unit may still move to a lower line; it just can never be split. The gap counts as one character, so the width budget is unchanged.

OS version sets the fallback. A device that cannot measure itself (older WebKit, a headless browser) falls back to its platform's known width, and the number of preview lines comes from a version table:

PlatformOne body line holdsCollapsed linesNotes
Android~34 chars on a 360 px phone1Android 8+ (Oreo) adds the tall expanded card
iOS / iPadOS~40 chars2 on iOS 15+, else 1Version sets the banner preview
macOS~52 chars2
Windows / Linux / ChromeOS~46–48 chars2

The same table bounds a measured value that is implausibly far from what the platform expects at the reported viewport, so a broken measurement cannot produce a bar that overflows the notification card. A device that reports nothing at all is treated as a phone on an unknown platform: one body line, conservative width.

Everything else rides the body, under the bar. The notification title is always just the chat name — the slot the OS clips first — so the status facts live in the body, where a wider/taller device simply shows more of them. Each fact is a tier, added top-down while it fits the surface's width and height:

OrderFactExample
1Running messageRefactoring the composer
2Position in the work2 of 5
3Turn usage12.4K tok · $0.0312
4Elapsed time12s
5Toolshell
6Modelgpt-5-mini

A one-line surface keeps the bar and its single most important fact; a preview-sized one shows the top three; an expanded notification shows all six. Missing facts simply do not appear, so a plain model round shows only the model and no tool. A slot that would wrap is dropped rather than wrapped, and a path or model id that has to be clipped keeps its tail (...transcript.js) rather than its prefix. A completion replaces the counts row (the position is no longer the news), and an error keeps its message plus whatever context the turn had reached.

The notification title carries the chat name and does not change with the device.

Every status shares one shape. A two-line surface:

[####------] 40%
Refactoring the composer
2 of 5
12.4K tok · $0.0312

A one-line surface, where the bar shares its row:

[###---] 40% · Refactoring...

Progress from a nested subagent run flows to the same progress_update channel as top-level calls, so a delegated agent that reports progress still sends the updatable push and the transcript progress card on the parent chat.