ADR-0047: Session Notifications — Event Routing, Channels, and the Service-Worker Bridge
Status
Proposed — 2026-06-19. Formalizes docs/ideas/0002-notifications.md. Depends on ADR-0046 (service worker) only for the optional Web Push channel.
Context
Concrete need: tell the operator that a session needs them — "session X is asking a question" (checkpoint / ask / approval gate) and "session X complete" — even when they aren't looking at it.
The signal already exists daemon-side. The events WS channel carries run.started / run.ended (ADR-0016 streaming), and checkpoints/asks are Mastra suspend points. What's missing is presentation (which surface, how loud) and reach when the operator is away (app closed).
A correction to the obvious framing: an OS-level notification does not universally require the service worker. The SW is required for exactly one path — Web Push (browser-delivered, same device). The plugin-push path the idea doc proposed (Pushover, and by extension ntfy/Telegram) reaches the OS with no SW at all: the daemon calls the service's API and the notification surfaces in that service's app. This ADR couples to ADR-0046 only insofar as we choose to ship the Web Push channel.
Decision drivers
- Operator agency; no forced external dependencies; self-hostable preferred.
- Don't double-notify — respect what surface the operator is already looking at.
- Settings layering from the idea doc: system default → project-local → per-session bell override.
- Channels must be pluggable (existing plugin notification-service intent, see
plugin-host.md).
Decision
Establish an event → router → channel pipeline.
Events (normalized to two classes).
attention.required(checkpoint / ask / approval gate — actionable, persistent) andrun.completed(informational). Each carriessession_id, session name,project_id, a short summary, and a deep-link target.Presence/focus tiers (formalizing the idea-doc matrix, identical for both event classes):
- Focused on that session → in-session only: bell tri-state update, no toast, no sound.
- App open, elsewhere or backgrounded-but-alive → toast (sonner) when visible; SW
registration.showNotification()when the tab is alive but hidden. - App closed (no live WS) → tier-3 channel(s).
Gating: the daemon already tracks live
eventssubscriptions per operator. If ≥1 live connection exists, deliver via the stream and let the client pick tier 1 vs 2 by Page Visibility. ⛔ Tier-3 channels fire only when the operator has zero live connections.Channel abstraction. Channels are registered services with a uniform
send(notification)contract. Built-in:in-app(WS → client). Optional built-in:web-push(SW). Plugin channels: ntfy / pushover / telegram / etc., via the plugin notification-service mechanism.Tier-3 mechanism — plugin-push is primary; Web Push is optional and deferred. Rationale: Web Push's delivery leg cannot be self-hosted (always routes through FCM/Apple/Mozilla), iOS requires an installed PWA and is unreliable, and one-subscription-per-SW collides with the multi-daemon model. A self-hostable plugin channel (ntfy) dodges all three and fits the ethos; Pushover (idea doc) is a fine hosted alternative. Both are just channels. If Web Push is later enabled it rides ADR-0046's SW under the constraints in §6.
Settings precedence. A routing table of
event_class × channel, held as system defaults, overridden inproject.local.yaml, overridden per-session via the session bell. ⛔ Overrides are set-not-merge (per the existing federation rule), so a session override fully replaces the resolved routing for that session.Web Push specifics (if/when enabled). VAPID keypair, daemon holds the private key. ⛔ A SW registration holds exactly one
PushSubscription; changing the VAPID key requires unsubscribe-then-resubscribe — so multi-daemon Web Push needs a shared VAPID identity or a push relay (deferred, see Out of scope).userVisibleOnly: trueis mandatory — every push must show a notification, so push cannot be used for silent state sync. Permission is requested only from an explicit operator gesture, never on load. The subscription is POSTed to the daemon over bearer auth and stored in SQLite. SWpush→showNotification;notificationclick→ focus/open the session at the checkpoint. ⛔ No blind approve/reject from the notification body — the decision happens in-app with full context (operator agency).
Consequences
Commits us to
- The event → router → channel spine, with presence gating on live WS subscription count.
- A channel plugin contract (
send(notification)) consistent with the plugin host.
Forecloses
- Approving/rejecting a checkpoint directly from a notification (deliberate — operator agency).
- Silent Web Push (spec-disallowed regardless).
Makes easier
- The two requested events work at tiers 1–2 immediately on the existing
eventsstream, with little or no SW involvement. - Adding a delivery target later (ntfy, pushover, telegram, email) is a plugin, not a core change.
Makes harder
- Per-device presence. v1 uses per-operator presence: tier-3 is suppressed if any client is connected, which can miss a closed phone while a desktop is open. Multi-device refinement deferred.
- Multi-daemon Web Push (shared VAPID vs relay) — deferred until/unless Web Push is adopted.
Alternatives considered
- Web Push as the primary (or only) tier-3 path. Rejected as primary: cannot self-host the delivery leg, iOS install requirement + flakiness, one-subscription-per-SW vs multi-daemon. Kept as an optional built-in.
- Daemon shells out to OS notifications directly (
notify-send/osascript). Rejected: the daemon may not run on the operator's machine (homelab / k8s). - No presence gating; always dispatch every channel. Rejected: double-notifies and, for Web Push, forces a visible OS notification even when the operator is staring at the session.
Implementation checklist
- Normalize
attention.required/run.completedon theeventschannel with deep-link payload. - Router with presence gate (live
eventssubscription count per operator); ⛔ tier-3 dispatch only at zero live connections. - In-app channel: bell tri-state + sonner toast; Page Visibility selects tier 1 vs 2; SW
showNotification()for hidden-but-alive tabs. - Channel contract
send(notification); register the built-inin-app; document the plugin channel method againstplugin-host.md. - Reference tier-3 plugin channel — ntfy (self-hostable); pushover optional.
- Settings: system-default routing table,
project.local.yamloverride, per-session bell override; ⛔ set-not-merge on overrides. - (Optional, ADR-0046-dependent)
web-pushchannel: VAPID, subscribe from an operator gesture, store subscription in SQLite, SWpush/notificationclickhandlers; ⛔ document one-subscription-per-SW and the multi-daemon limitation.
Out of scope / related
- Multi-daemon Web Push topology (shared VAPID identity vs dedicated push relay) — follow-up if Web Push is adopted.
- Per-device (vs per-operator) presence.
- Approval-gate primitive in the workflow DSL (separate roadmap item);
attention.requiredwill emit from it once it lands.