# 08 — Push Notifications, Outbox & Sync The gateway can't reach a sleeping phone directly. Push goes through a cloud relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`). Privacy: FCM push metadata (notification title, device token) is routed through Google's servers — for truly private communication use ntfy (self-hosted), which keeps everything on your own infrastructure. ## 8.1 When push fires - A frame targets a `chat_id` whose device is **disconnected** (no live SSE/long-poll subscriber) → drop to **outbox** + fire **push** — with one refinement, *turn-aware push* (below). - Also fire push for high-priority foreground events the user should see even if the app is backgrounded (approvals, clarifies, cron completions) — the app decides whether to also show an in-app banner. - If the device is **connected**, no push (the live frame is enough). ### 8.1.1 Turn-aware push (one push per turn, final answer as body) An agent turn can span minutes and emit several completed status messages ("researching X…", "found Y…", "writing findings…", final answer). Pushing each parked message would spam an offline user with the steps in between. So while the agent's turn is **in flight** (hermes holds the typing indicator on from turn start until the handler's `finally` at turn end), normal-priority `message` / `message.stop` / `media.offer` frames that park with no live device are **held back** per chat instead of pushing; the latest one is pushed when the turn ends (`stop_typing`), so the offline user gets **one push with the final answer**. Details: - Turn state is tracked per `chat_id` from the typing indicator (`send_typing` → in flight, `stop_typing` → ended; hermes fires `stop_typing` in the handler's `finally`, after the final send, so the flush always sees the final frame). - The held-back frame is still parked in the outbox — sync catch-up is unaffected; only the push is deferred. - **High-priority notifications** (approval/clarify/cron) push immediately, even mid-turn — they need user action. - If the device **reconnects mid-turn** (SSE/long-poll open), the held-back push is dropped: the app syncs the parked frames and must not get a duplicate push at turn end. - If the turn ends while the device is live, nothing is pushed (the frames were delivered live / synced). - Turns without a typing indicator (e.g. typing disabled in config) and non-turn deliveries (cron, standalone sends) push immediately as before. - Best-effort: a gateway crash mid-turn loses the held-back push (the frames remain in the outbox and sync on reconnect). ## 8.2 `PushBackend` interface (`push.py`) ```python class PushBackend(Protocol): name: str async def send(self, *, device_id: str, chat_id: str, title: str, body: str, data: dict) -> bool: ... def configured(self) -> bool: ... ``` Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`). ### 8.2.1 `FcmBackend` (optional; metadata via Google) - **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived OAuth2 access token (cached, refreshed before expiry). - Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service account (simpler, but legacy). - Target = the device's **FCM token** (registered via `hello` / `fcm.register`, stored in `devices.db`). - Payload: - `notification {title, body}` → system notification (tap → open app). - `data {chat_id, thread_id, kind, message_id, cursor}` → app uses to `sync`. - **Data-only option:** for background catch-up, send a data message (silent) so the app's `FirebaseMessagingService` wakes a foreground service and syncs without a visible notification (used for non-urgent updates). - Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few devices). ### 8.2.2 `NtfyBackend` (default, self-host friendly) - Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter). - Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via `httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a JSON `data` attachment (`{chat_id, thread_id, kind, cursor}`). - The app subscribes to the topic (via an ntfy client lib or a lightweight listener) to receive pushes without Firebase. - Best for users who self-host ntfy and want zero Firebase. ## 8.3 Outbox (`outbox.py`) - SQLite `outbox.db`: rows `{cursor (monotonic), chat_id, thread_id, frame_json, ts, delivered}`. - **Write** on every outbound frame that has no live subscriber (or always, then mark delivered on live send — simpler + crash-safe). - **Retention:** `outbox_retention_hours` (default 72h); prune on write. - **Cursor:** global monotonic int; `hello.ack` returns the current cursor; `sync {cursor}` replays rows with `cursor > given`. - Bounded: if the outbox grows past a cap (e.g. 5k rows), oldest are pruned and a `notification {kind:"generic", body:"Older messages pruned"}` is sent. ## 8.4 Sync (reconnect catch-up) 1. App reconnects → `hello` → `hello.ack {sync_cursor}`. 2. App sends `sync {cursor: }`. 3. Server replays outbox frames (in cursor order) → app applies them (messages, tools, channels, media offers). 4. Server sends `sync.done {cursor: }`. 5. App updates its local cursor (Room) — idempotent (dedupe by `message_id`). This makes the app **eventually consistent** across disconnects, restarts, and gateway restarts. ## 8.5 App-side push handling (Android) - **`FirebaseMessagingService.onMessageReceived`** (foreground): show in-app banner + optionally sync. - **`onNewToken`** → `fcm.register` the new token. - **Background data message** → start a **foreground service** (low-priority, notification channel "Sync") → open WS → `sync` → stop. - **Notification channels** (Android 8+): one per chat so cron channels can have their own style/priority (e.g. "Cron Reports" = high, "Default" = default). Tapping a notification deep-links to the chat/thread. - **ntfy mode:** a foreground service maintains the ntfy subscription; incoming events trigger the same sync path. ## 8.6 In-app banners (foreground) `notification` frames (e.g. `channel_renamed`, `approval`, `clarify`, `cron`) render as a transient banner above the composer (like the reference screenshot's "ARIA hat Thema … umbenannt"). Dismissible; high-priority ones (approval/clarify) persist until acted on. ## 8.7 Security - FCM tokens are per-device, revocable; stored only in `devices.db`. - Push payloads carry **no secrets** and no full message bodies larger than a short preview (privacy on lock screen). Full content is fetched via `sync` over the authenticated WS. - ntfy: use a **private topic + auth token** for any real trust boundary (hermes ntfy adapter guidance). ## 8.8 Notification dedupe (push vs. sync) A message sent while the device is offline is notified **twice** by naive design: once by the push (FCM displays the `notification` payload), and again when the app reconnects, syncs the outbox, and mirrors the replayed frames to system notifications (the background-mirror path, §8.5). The fix is a per- device push watermark: - **Gateway** records the highest outbox cursor delivered to each device via the push backend (`devices.last_pushed_cursor`, advanced only on a *successful* send) and returns it in `hello.ack` as `last_pushed_cursor`. - **Gateway** coalesces back-to-back pushes per chat (5 s window): a cron delivery parks a notification frame AND a message frame, and only the first pushes — the second reaches the app via sync (tap the first notification). - **Gateway** tags every `sync`-replayed frame with its outbox cursor in the frame envelope (`cursor`; live frames carry none). - **App** skips system notifications for replayed frames with `cursor <= lastPushedCursor` (they already woke the device). Live frames are never suppressed — that is exactly the case where no push fired and the app must notify itself. - **App** FCM handler (`onMessageReceived`) posts nothing when the WS is connected (the background-mirror path handles it) and nothing when the message carried a `notification` payload (FCM already displayed it); data-only messages are the exception (the app must display them itself). Residual edge: FCM is at-least-once, so a lost device ack can still produce a duplicate *system-displayed* notification (two `FCM-Notification:*` ids). The designed evolution is the data-only push option (§8.2.1), which moves display into the app and lets it use a stable per-message notification id.