Skip to content

Notifier UI integration: notification API, SSE replay, dashboard center, and AO-app desktop #56

@whoisasx

Description

@whoisasx

Summary

Build the user-facing notification surfaces on top of the durable notification foundation (#54) and central notifier runtime (#55). This issue owns REST APIs, SSE replay, dashboard notification center behavior, notification actions, and AO Electron app desktop notifications.

This issue excludes Slack, webhook, Discord, and all legacy desktop fallback backends. Desktop delivery must happen through the AO Electron app only.

Depends On

Context

The Go rewrite currently has:

  • durable CDC types and poller/broadcaster in backend/internal/cdc/*
  • production CDC wiring in backend/cdc_wiring.go
  • API route shell in backend/internal/httpd/api.go
  • OpenAPI in backend/internal/httpd/apispec/openapi.yaml
  • a blank Electron shell in frontend/src/main.ts

After #54 and #55, notifications and desktop delivery rows should exist in SQLite and emit CDC events. This issue makes those records usable by humans:

SQLite notifications + delivery state
  -> REST snapshot API
  -> /events SSE replay/live stream
  -> dashboard notification center
  -> Electron AO app native desktop notifications

Goals

  • Add notification REST APIs.
  • Add /events SSE with durable replay and live updates.
  • Add dashboard notification center backed by REST + SSE, not JSONL or localStorage.
  • Add secure allowlisted notification actions.
  • Add AO Electron app desktop notifications using Electron's native Notification API.
  • Persist read/dismiss state server-side.
  • Persist Electron last delivered SSE sequence to avoid duplicate desktop notifications after restart.

Non-Goals

  • No Slack.
  • No webhook.
  • No Discord.
  • No external notifier plugin work.
  • No terminal-notifier.
  • No osascript.
  • No Linux notify-send.
  • No Windows PowerShell toast.
  • No arbitrary callback endpoints in notification actions.

Proposed Files

Backend add:

backend/internal/httpd/controllers/notifications.go
backend/internal/httpd/controllers/notifications_test.go
backend/internal/httpd/sse/events.go
backend/internal/httpd/sse/events_test.go
backend/internal/notification/actions.go
backend/internal/notification/actions_test.go

Backend modify:

backend/internal/httpd/api.go
backend/internal/httpd/router.go
backend/internal/httpd/apispec/openapi.yaml
backend/internal/cdc/event.go, if #54/#55 did not add all notification event constants
backend/cdc_wiring.go, if broadcaster dependencies need to be passed into router deps
backend/main.go, to pass notification/API/SSE deps

Frontend add/modify:

frontend/src/notifications.ts
frontend/src/main.ts
frontend/src/notification-state.ts, if renderer/shared state exists by then
frontend/src/notification-state.test.ts
frontend/src/notifications.test.ts

Dashboard files will depend on where the dashboard UI lands. If the current frontend is still only Electron shell, create the notification client/state module now and wire UI components when the dashboard shell exists.

API Contracts

GET /api/v1/notifications

Query params:

projectId?: string
sessionId?: string
unreadOnly?: boolean
includeArchived?: boolean = false
limit?: number = 50, max 500
beforeSeq?: int64

Response:

{
  "notifications": [
    {
      "seq": 123,
      "id": "ntf_abc",
      "receivedAt": "2026-05-31T10:30:00Z",
      "readAt": null,
      "archivedAt": null,
      "event": {
        "id": "ntf_abc",
        "type": "session.needs_input",
        "priority": "urgent",
        "sessionId": "ao-7",
        "projectId": "ao",
        "timestamp": "2026-05-31T10:30:00Z",
        "message": "Agent needs input to continue.",
        "data": {
          "schemaVersion": 3,
          "semanticType": "session.needs_input",
          "subject": {
            "session": { "id": "ao-7", "projectId": "ao" }
          }
        }
      },
      "actions": [
        {
          "id": "open-session",
          "kind": "open-session",
          "label": "Open session",
          "route": "/projects/ao/sessions/ao-7"
        }
      ]
    }
  ],
  "unreadCount": 3,
  "limit": 50,
  "nextBeforeSeq": 100
}

Rules:

  • Default excludes archived notifications.
  • unreadOnly=true returns only rows with readAt == null.
  • beforeSeq pages older records.
  • limit clamps to 1..500.

GET /api/v1/notifications/{id}

Response:

{ "notification": NotificationRecord }

Errors:

404 NOTIFICATION_NOT_FOUND

PATCH /api/v1/notifications/{id}

Body:

{
  "read": true,
  "archived": false
}

Rules:

  • read=true sets read_at if currently null.
  • read=false clears read_at.
  • archived=true sets archived_at.
  • archived=false clears archived_at only if product wants unarchive; otherwise reject until we support it.
  • Repeated idempotent updates return the current record.

Response:

{ "notification": NotificationRecord }

Errors:

400 INVALID_NOTIFICATION_UPDATE
404 NOTIFICATION_NOT_FOUND

POST /api/v1/notifications/read-all

Body:

{
  "projectId": "ao",
  "sessionId": "ao-7"
}

Both fields optional. If omitted, mark all visible notifications read.

Response:

{ "updated": 12 }

POST /api/v1/notifications/{id}/actions/{actionId}

Headers:

X-AO-Action-Token: <token>

Body:

{}

Response:

{
  "ok": true,
  "actionId": "open-session",
  "kind": "open-session",
  "result": {
    "route": "/projects/ao/sessions/ao-7"
  }
}

Errors:

403 ACTION_NOT_ALLOWED
404 NOTIFICATION_NOT_FOUND
404 ACTION_NOT_FOUND
409 ACTION_PRECONDITION_FAILED
422 ACTION_TARGET_INVALID

Action Allowlist

Allowed action kinds:

open-session
open-pr
open-review
open-ci
restore-session
send-message
merge-pr
mark-read
dismiss

Rules:

  • No arbitrary callbackEndpoint field.
  • No arbitrary POST/HTTP callbacks from notification payloads.
  • External URLs must be HTTPS.
  • External host allowlist starts with:
    • github.com
    • gitlab.com
    • linear.app
  • merge-pr must re-check PR state/mergeability before executing.
  • kill-session should not be emitted as a notification action. Keep kill behind dashboard UI confirmation.
  • Desktop native action buttons should only expose low-risk actions initially:
    • open-session
    • open-pr
    • restore-session
    • mark-read

SSE Contract

Add GET /events outside /api/v1 REST timeout.

Query params:

after?: int64
projectId?: string
topics?: comma-separated list: sessions,prs,notifications

Headers:

Last-Event-ID?: int64

Offset precedence:

after query param
then Last-Event-ID header
then live-only from current head

Response type:

text/event-stream

SSE message format:

id: 123
event: notification_created
data: {"seq":123,"type":"notification_created","projectId":"ao","sessionId":"ao-7","payload":{...},"createdAt":"2026-05-31T10:30:00Z"}

Rules:

  • id: is always change_log.seq.
  • event: is always the CDC event type.
  • Send retry: 2000 once after connect.
  • Send heartbeat comments every 15 seconds.
  • Invalid after or Last-Event-ID returns 400 APIError before stream starts.
  • Slow clients should receive a final stream_error event with CLIENT_TOO_SLOW, then the server closes the connection.
  • Clients recover by reconnecting with the last delivered id.

SSE Replay Algorithm

Avoid the gap between durable replay and live subscription.

Algorithm:

  1. Parse filters and cursor.
  2. Subscribe to cdc.Broadcaster immediately.
  3. Buffer matching live events in memory while replay runs.
  4. Replay change_log rows after cursor in batches of 512.
  5. Track lastSentSeq.
  6. Drain buffered events with seq > lastSentSeq.
  7. Continue live streaming.
  8. On disconnect, unsubscribe.

Fresh clients without offset can start live-only because dashboard loads the snapshot through REST first.

Filtered streams warning:

  • If a client changes projectId or topics, it should reset its offset or pass an explicit after chosen for the new filter.

Dashboard Notification Center

Build a notification center backed by REST + SSE.

Required UX states:

loading
empty all
empty unread
error with stale records retained
reconnecting

Required behavior:

  • bell button in top-level dashboard chrome
  • unread badge count
  • tabs/filter: All and Unread
  • priority styles for urgent/action/warning/info
  • success tone for merge-ready and completed work
  • mark one read
  • mark all read
  • dismiss/archive notification
  • safe internal links to project/session pages
  • safe external links to PR/CI/review URLs
  • action pending state
  • action success/failure inline feedback
  • server-side read state, not browser localStorage

Data flow:

initial render -> GET /api/v1/notifications
live update -> /events?topics=notifications
notification_created -> append/prepend if not duplicate
notification_updated -> patch existing row
PATCH/read-all response -> reconcile state

Electron AO-App Desktop Notifications

Use Electron's native main-process Notification API. Do not shell out to notifier binaries.

Main-process flow:

  1. Start or wait for Go daemon readiness.
  2. Open dashboard window to daemon origin.
  3. Start one main-process SSE client to /events?topics=notifications.
  4. Persist last delivered seq under Electron app.getPath("userData").
  5. Reconnect with Last-Event-ID.
  6. On notification_created, check desktop eligibility from payload/delivery state/settings.
  7. Deduplicate by notification id and/or delivery id.
  8. Build native notification title/body from event payload.
  9. Call new Notification({...}).show().
  10. Keep notification object references until close or failed so event handlers survive.
  11. On click, focus or create AO window and navigate to the session route.
  12. On click, mark notification read through API.
  13. On action, map native action index to allowlisted action id and POST action API.
  14. On failure, record/log failure and leave dashboard notification intact.

Desktop display defaults:

urgent: show desktop + sound
action: show desktop, no sound by default
warning: dashboard only by default
info: dashboard only by default

Security / Safety

  • Action API requires X-AO-Action-Token.
  • Token should be generated by daemon and made available only to the Electron app/session that needs it.
  • Do not allow arbitrary callback URLs from notification payloads.
  • Re-check server-side preconditions for state-changing actions like merge-pr.
  • Do not trust renderer-side filtering for external links.
  • Only loopback origin should be used.

Tests

Backend tests:

  • notification list/get filters
  • unread count
  • pagination by beforeSeq
  • mark read/unread/archive idempotency
  • read-all scoped by project/session
  • action allowlist accepts allowed actions
  • action allowlist rejects arbitrary URL/callback
  • external URL host allowlist
  • merge action precondition failure
  • OpenAPI parses and includes all new routes

SSE tests:

  • fresh live-only stream
  • replay with after
  • replay with Last-Event-ID
  • after precedence over header
  • project filtering
  • topic filtering
  • replay batching
  • no duplicate events across replay/live buffer boundary
  • heartbeat emitted
  • invalid offsets return 400
  • slow client closes safely

Dashboard tests:

  • reducer/client handles initial REST snapshot
  • append notification_created
  • patch notification_updated
  • unread badge
  • all/unread filters
  • mark read
  • mark all read
  • dismiss
  • safe internal route links
  • reject unsafe external links:
    • javascript:
    • data:
    • non-HTTPS
    • untrusted hosts
  • action pending/success/failure states
  • reconnecting state keeps stale records visible

Electron tests:

  • mock Notification and assert native payload
  • dedupe by notification id/seq
  • persisted last seq reconnect
  • click focuses window and navigates to session
  • click marks notification read
  • action index maps to allowlisted action id
  • unsupported notifications do not crash app
  • failed notification is recorded/logged
  • static guard that frontend does not invoke:
    • terminal-notifier
    • osascript
    • notify-send
    • powershell.exe
    • Slack
    • Discord
    • webhook delivery

Acceptance Criteria

  • GET /api/v1/notifications returns persisted notifications with unread count and pagination.
  • PATCH /api/v1/notifications/{id} updates read/archive state and emits CDC.
  • GET /events supports durable replay and live notification updates without duplicates.
  • Dashboard notification center updates live from SSE and survives reload through REST.
  • Electron AO app shows native desktop notifications for desktop-eligible notifications.
  • Electron desktop notification click opens the relevant AO session.
  • Notification actions are allowlisted and cannot execute arbitrary callbacks or untrusted URLs.
  • No Slack/webhook/Discord code is added.
  • No legacy desktop fallback backend is added.
  • Backend tests pass.
  • Frontend/Electron tests pass.

Risks / Follow-Ups

  • Notifications and change_log are unbounded. Add retention/pruning in a later issue.
  • Native notification action support varies by OS. Always support click-to-open as the reliable baseline.
  • Packaged macOS notification reliability may depend on app signing/bundle identity.
  • If Electron is fully quit, OS desktop delivery cannot happen. Durability is preserved by backend notification records; delivery can happen when AO app starts again if still relevant.
  • Reusing SSE offsets across different filters can confuse clients. Document reset behavior in client code.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions