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:
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:
Offset precedence:
after query param
then Last-Event-ID header
then live-only from current head
Response type:
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:
- Parse filters and cursor.
- Subscribe to
cdc.Broadcaster immediately.
- Buffer matching live events in memory while replay runs.
- Replay
change_log rows after cursor in batches of 512.
- Track
lastSentSeq.
- Drain buffered events with
seq > lastSentSeq.
- Continue live streaming.
- 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:
- Start or wait for Go daemon readiness.
- Open dashboard window to daemon origin.
- Start one main-process SSE client to
/events?topics=notifications.
- Persist last delivered
seq under Electron app.getPath("userData").
- Reconnect with
Last-Event-ID.
- On
notification_created, check desktop eligibility from payload/delivery state/settings.
- Deduplicate by notification id and/or delivery id.
- Build native notification title/body from event payload.
- Call
new Notification({...}).show().
- Keep notification object references until
close or failed so event handlers survive.
- On click, focus or create AO window and navigate to the session route.
- On click, mark notification read through API.
- On action, map native action index to allowlisted action id and POST action API.
- 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.
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:
backend/internal/cdc/*backend/cdc_wiring.gobackend/internal/httpd/api.gobackend/internal/httpd/apispec/openapi.yamlfrontend/src/main.tsAfter #54 and #55, notifications and desktop delivery rows should exist in SQLite and emit CDC events. This issue makes those records usable by humans:
Goals
/eventsSSE with durable replay and live updates.NotificationAPI.Non-Goals
terminal-notifier.osascript.notify-send.Proposed Files
Backend add:
Backend modify:
Frontend add/modify:
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/notificationsQuery params:
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:
unreadOnly=truereturns only rows withreadAt == null.beforeSeqpages older records.limitclamps to1..500.GET /api/v1/notifications/{id}Response:
{ "notification": NotificationRecord }Errors:
PATCH /api/v1/notifications/{id}Body:
{ "read": true, "archived": false }Rules:
read=truesetsread_atif currently null.read=falseclearsread_at.archived=truesetsarchived_at.archived=falseclearsarchived_atonly if product wants unarchive; otherwise reject until we support it.Response:
{ "notification": NotificationRecord }Errors:
POST /api/v1/notifications/read-allBody:
{ "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:
Body:
{}Response:
{ "ok": true, "actionId": "open-session", "kind": "open-session", "result": { "route": "/projects/ao/sessions/ao-7" } }Errors:
Action Allowlist
Allowed action kinds:
Rules:
callbackEndpointfield.github.comgitlab.comlinear.appmerge-prmust re-check PR state/mergeability before executing.kill-sessionshould not be emitted as a notification action. Keep kill behind dashboard UI confirmation.open-sessionopen-prrestore-sessionmark-readSSE Contract
Add
GET /eventsoutside/api/v1REST timeout.Query params:
Headers:
Offset precedence:
Response type:
SSE message format:
Rules:
id:is alwayschange_log.seq.event:is always the CDC event type.retry: 2000once after connect.afterorLast-Event-IDreturns400 APIErrorbefore stream starts.stream_errorevent withCLIENT_TOO_SLOW, then the server closes the connection.id.SSE Replay Algorithm
Avoid the gap between durable replay and live subscription.
Algorithm:
cdc.Broadcasterimmediately.change_logrows after cursor in batches of 512.lastSentSeq.seq > lastSentSeq.Fresh clients without offset can start live-only because dashboard loads the snapshot through REST first.
Filtered streams warning:
projectIdortopics, it should reset its offset or pass an explicitafterchosen for the new filter.Dashboard Notification Center
Build a notification center backed by REST + SSE.
Required UX states:
Required behavior:
localStorageData flow:
Electron AO-App Desktop Notifications
Use Electron's native main-process
NotificationAPI. Do not shell out to notifier binaries.Main-process flow:
/events?topics=notifications.sequnder Electronapp.getPath("userData").Last-Event-ID.notification_created, check desktop eligibility from payload/delivery state/settings.new Notification({...}).show().closeorfailedso event handlers survive.Desktop display defaults:
Security / Safety
X-AO-Action-Token.merge-pr.Tests
Backend tests:
beforeSeqSSE tests:
afterLast-Event-IDafterprecedence over headerDashboard tests:
notification_creatednotification_updatedjavascript:data:Electron tests:
Notificationand assert native payloadterminal-notifierosascriptnotify-sendpowershell.exeAcceptance Criteria
GET /api/v1/notificationsreturns persisted notifications with unread count and pagination.PATCH /api/v1/notifications/{id}updates read/archive state and emits CDC.GET /eventssupports durable replay and live notification updates without duplicates.Risks / Follow-Ups
change_logare unbounded. Add retention/pruning in a later issue.