8.3 KiB
08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. Decision: FCM primary, ntfy fallback (IRIS_PUSH_BACKEND).
8.1 When push fires
- A frame targets a
chat_idwhose 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_idfrom the typing indicator (send_typing→ in flight,stop_typing→ ended; hermes firesstop_typingin the handler'sfinally, 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)
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 (fcm default, ntfy).
8.2.1 FcmBackend (primary)
- FCM HTTP v1 API via
httpx(core dep). Auth = Firebase service account (IRIS_FCM_SERVICE_ACCOUNTJSON 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 indevices.db). - Payload:
notification {title, body}→ system notification (tap → open app).data {chat_id, thread_id, kind, message_id, cursor}→ app uses tosync.
- Data-only option: for background catch-up, send a data message (silent) so
the app's
FirebaseMessagingServicewakes 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 (fallback, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
- Publish to
NTFY_TOPIConNTFY_SERVER_URL(defaulthttps://ntfy.sh) viahttpxPOST, with anX-Title/X-Message/X-Tag/X-Priorityand a JSONdataattachment ({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.ackreturns the current cursor;sync {cursor}replays rows withcursor > 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)
- App reconnects →
hello→hello.ack {sync_cursor}. - App sends
sync {cursor: <last seen>}. - Server replays outbox frames (in cursor order) → app applies them (messages, tools, channels, media offers).
- Server sends
sync.done {cursor: <new>}. - 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.registerthe 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
syncover 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 inhello.ackaslast_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 anotificationpayload (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.