Proposal: User notifications service
Background
Today there is no mechanism for an automation (or any agent run) to actively push a
message to the user who owns it. The closest things that exist are:
- Toasts in agent-canvas (
react-hot-toast) — only fired from frontend code paths.
There is no API a backend can call to make a toast appear in an open browser.
useAgentNotification hook — plays a sound and updates the tab favicon, but only
for the currently-open conversation's agent state. It does not cross conversations.
- The conversation transcript itself — users have to navigate into the automation's
Runs list and read the conversation. This is "pull", not "push".
In practice, the only way an automation reaches the user out-of-band today is by going
through an external channel (Slack, email, etc.) via MCP integrations. That works but
requires every user to configure a third-party service for what should be a first-class
in-product capability.
This issue proposes a small user-notifications service so any automation (and, by
extension, any agent run) can push a notification that appears in the user's open
Agent Canvas tabs — and optionally as an OS-level desktop notification.
Goals
- Per-user notification records with title, body, severity, and optional deep link.
- REST API to create, list, mark-viewed, and delete notifications.
- WebSocket channel for real-time delivery to open browser tabs.
- Frontend integration: in-page toast on receive; optional desktop notification via
the Web Notifications API, deduplicated across tabs.
- A path for automations and agents to discover and use this without bespoke wiring.
- Per-user preferences (desktop on/off, severity threshold) persisted server-side.
Non-goals (v1)
- Email / SMS / Slack / push-to-mobile fan-out. (External delivery stays in MCP land.)
- Web Push API / Service Worker delivery when the tab is closed. (Tempting v2, but
significantly bigger surface — VAPID keys, server-side push, browser-vendor quirks.)
- Per-source mute lists, snooze, or quiet hours. (Possible v1.5.)
- Cross-user broadcast or shared notifications.
Where it lives
Recommendation: implement inside OpenHands/automation, but mount under a
source-agnostic ingress path (/api/notifications/*) so it can move to a dedicated
service later without breaking callers.
Rationale:
| Option |
Verdict |
| Agent server |
❌ Wrong scope — per-sandbox, dies with the conversation. |
| Automation service |
✅ Per-user auth, persistent DB, background loops, ingress already terminates here, automations are the canonical first caller. |
| OpenHands Cloud "core" backend |
✅ Conceptually correct, but agent-canvas deliberately bypasses the cloud backend — anything we put there isn't usable in self-hosted agent-canvas. |
| New dedicated microservice |
❌ Operational overhead (Helm, DB, deploy) without proportional v1 benefit. |
This stretches the automation service beyond "automations" into general user-facing
pubsub. A future rename ("activity service" / "user-events service") may be warranted,
but should not block v1.
Data model
Two new tables in the automation Postgres.
notifications
| Column |
Type |
Notes |
id |
uuid (pk) |
Server-generated. |
user_id |
text, indexed |
Owner; same identity used by existing automation tables. |
title |
text |
Short — surfaced in toast title and OS notification title. |
description |
text |
Body. Plain text; markdown rendering deferred to v2 if wanted. |
status |
enum: INFO | WARN | ERROR |
See "Status / severity" below. |
kind |
text, nullable |
Freeform routing key, e.g. automation.run.completed. |
link |
text, nullable |
URL to deep-link on click (conversation, automation run, etc.). |
source |
text, nullable |
Origin identifier, e.g. automation:<uuid>, conversation:<uuid>. |
viewed_at |
timestamptz, nullable |
Null until the user marks it viewed. |
client_token |
text, nullable, unique per user_id |
Optional idempotency key from creator. |
created_at |
timestamptz |
Default now(). |
Indexes: (user_id, created_at desc), (user_id, viewed_at), (user_id, client_token).
Status / severity
A three-level enum (INFO | WARN | ERROR) supports severity thresholds
("desktop-notify on WARN and above") cleanly, and is more legible than an integer
severity at the call site. kind stays orthogonal so the UI can pick icons / route
on event type without enum churn.
notification_preferences
| Column |
Type |
Notes |
user_id |
text (pk) |
One row per user. |
desktop_enabled |
bool |
User intent. The browser-permission state is read client-side. |
desktop_min_severity |
enum: INFO | WARN | ERROR |
Below this → toast only. |
updated_at |
timestamptz |
|
Sound on/off is not added here — reuse the existing
enable_sound_notifications setting in agent-canvas (src/types/settings.ts,
useAgentNotification).
REST API
All under /api/notifications/v1. Auth: same X-API-Key (or session cookie) scheme
already used by /api/automation/v1.
| Method |
Path |
Purpose |
POST |
/notifications |
Create. Body: {title, description, status, kind?, link?, source?, client_token?}. Returns the row. |
GET |
/notifications |
List. Query: ?status=, ?viewed=, ?since=<ts>, ?limit=. |
GET |
/notifications/{id} |
Fetch one. |
PATCH |
/notifications/{id} |
Body: {viewed: true}. |
POST |
/notifications/mark-all-viewed |
Bulk mark. |
DELETE |
/notifications/{id} |
Single delete. |
DELETE |
/notifications |
Bulk delete. Body: {ids: [...]} or {before: <ts>}. |
GET |
/preferences |
Read prefs. Returns defaults if no row exists. |
PATCH |
/preferences |
Update prefs. |
Retention
Sweep in the existing watchdog_loop:
- Hard cap 200 notifications per user (oldest dropped FIFO).
- TTL 30 days from
created_at.
Both numbers are knobs in config.py.
Idempotency
If client_token is supplied and a row with the same (user_id, client_token) exists,
return the existing row instead of inserting. Lets automations retry safely.
Rate limiting
Soft per-user cap (e.g. 20/min). Above threshold: coalesce into a single notification
of the form "5 new notifications from <source>" to keep desktop notifications sane.
Hard cap (e.g. 100/min) returns 429.
WebSocket
GET /api/notifications/v1/ws — auth via Sec-WebSocket-Protocol header (token-in-URL
is avoided because URLs leak into logs).
Server-side state: dict[user_id, set[WebSocket]]. On POST /notifications:
- Persist the row.
- Fan out the JSON payload to every socket in
connections[user_id].
Delivery semantics: at-most-once over WS. The DB row is the source of truth.
On reconnect, the frontend reconciles via GET /notifications?since=<cursor> rather
than relying on the WS to replay.
Heartbeats: server sends {type: "ping"} every 30s; clients reply {type: "pong"}.
Idle sockets get dropped after 90s.
Frontend integration (agent-canvas)
Discovery / connection
- Add
notifications to VITE_RUNTIME_SERVICES_INFO and to the
<RUNTIME_SERVICES> system-message-suffix block (see scripts/dev-safe.mjs
and src/api/agent-server-adapter.ts). The block already documents the
automation service; this slots in next to it.
- Add a
useNotificationsSocket() hook that opens a WS to
/api/notifications/v1/ws, reconciles on connect, and exposes incoming
notifications via a Zustand/Jotai store (whatever matches the existing
pattern — likely TanStack Query cache + a tiny event emitter).
Toast bridge
Every incoming WS notification fires displaySuccessToast /
displayErrorToast (or a new displayWarnToast) from
src/utils/custom-toast-handlers.tsx. Toast fires in every open tab.
Desktop notification + multi-tab dedup
async function maybeFireDesktop(n: NotificationRecord) {
if (!prefs.desktop_enabled) return;
if (severityLt(n.status, prefs.desktop_min_severity)) return;
if (window.Notification?.permission !== "granted") return;
await navigator.locks.request(
`oh-notification:${n.id}`,
{ ifAvailable: true },
async (lock) => {
if (!lock) return; // another tab claimed it
new window.Notification(n.title, { body: n.description, tag: n.id });
},
);
}
navigator.locks with ifAvailable: true gives leader election for free; only the
tab that grabs the lock fires the OS notification. tag: n.id is a backstop so the
OS itself collapses any race-condition duplicates.
Toast fires unconditionally in every tab — it's harmless and the user reading a
different tab still needs to know.
Notification center UI
A new "bell" icon in the sidebar / header opens a panel listing recent notifications
(unviewed first), with mark-viewed and delete. Clicking a notification with a link
navigates there and marks viewed.
Settings UI
Add a "Notifications" section to the existing App Settings page (sits next to
enable_sound_notifications):
- "Desktop notifications" toggle (server pref).
- Severity threshold (server pref).
- Browser permission state, with a "Request permission" button and clear messaging
for the three possible states (granted / denied / default).
- Existing sound toggle stays where it is.
Discovery for agents
Two complementary paths, both should ship in v1:
-
notifications skill in OpenHands/extensions wrapping the REST call:
/notifications:notify --title "Build failed" --status ERROR --link <url>
Reusable from any conversation, not just automation-generated ones.
-
Inject a helper into the preset boilerplate (prompt + plugin presets in
openhands/automation/presets/). The generated entrypoint already pulls the
user's API key and host — add a notify_user(title, description, status="INFO")
helper alongside it so preset prompts can call it without extra setup.
The system-suffix entry (above) documents the raw REST shape so even agents without
the skill can curl it directly.
Cross-repo work
| Repo |
Change |
OpenHands/automation |
Tables + Alembic migration, notifications/ module (router, WS manager, retention sweep), preset boilerplate helper, ingress mount at /api/notifications/*. |
OpenHands/agent-canvas |
VITE_RUNTIME_SERVICES_INFO entry + suffix renderer, WS hook, toast bridge, desktop-notification dedup, notification center UI, settings UI. |
OpenHands/extensions |
notifications skill. |
Open questions
- Markdown in
description? Plain text is safer for v1; rendering markdown in
both the toast and the OS notification is fiddly.
- Should we suppress the desktop notification on the tab that originated the
work? The Web Notifications API doesn't auto-suppress for visible tabs on every
OS. My instinct: suppress only when document.visibilityState === "visible" and
the user has opted in to that behavior.
- Quiet hours / DND window — v1.5?
- Per-source mute ("shut up, automation X") — v2 unless someone screams.
- Notification grouping in the OS — should we use
tag for grouping by source
so multiple notifications from the same automation collapse? Or strict-per-id?
- What identity does an ad-hoc agent-server conversation present to the
notifications API? Automations have a clean per-user API key path; conversations
need either the same key surfaced via <RUNTIME_SERVICES> or a per-conversation
short-lived token. Likely fine to defer the conversation case until after
automations are wired.
Out of scope for v1 (recorded explicitly)
- Web Push / Service Worker (closed-tab delivery).
- Email / SMS / Slack / Discord fan-out (stays in MCP).
- Mobile push.
- Per-source mute, snooze, quiet hours.
- Markdown / rich content in the body.
- Shared / team notifications.
This issue was drafted by an AI agent (OpenHands) on behalf of @tofarr after a design discussion. Recommendations are opinionated starting points — push back where appropriate.
Proposal: User notifications service
Background
Today there is no mechanism for an automation (or any agent run) to actively push a
message to the user who owns it. The closest things that exist are:
react-hot-toast) — only fired from frontend code paths.There is no API a backend can call to make a toast appear in an open browser.
useAgentNotificationhook — plays a sound and updates the tab favicon, but onlyfor the currently-open conversation's agent state. It does not cross conversations.
Runs list and read the conversation. This is "pull", not "push".
In practice, the only way an automation reaches the user out-of-band today is by going
through an external channel (Slack, email, etc.) via MCP integrations. That works but
requires every user to configure a third-party service for what should be a first-class
in-product capability.
This issue proposes a small user-notifications service so any automation (and, by
extension, any agent run) can push a notification that appears in the user's open
Agent Canvas tabs — and optionally as an OS-level desktop notification.
Goals
the Web Notifications API, deduplicated across tabs.
Non-goals (v1)
significantly bigger surface — VAPID keys, server-side push, browser-vendor quirks.)
Where it lives
Recommendation: implement inside
OpenHands/automation, but mount under asource-agnostic ingress path (
/api/notifications/*) so it can move to a dedicatedservice later without breaking callers.
Rationale:
This stretches the automation service beyond "automations" into general user-facing
pubsub. A future rename ("activity service" / "user-events service") may be warranted,
but should not block v1.
Data model
Two new tables in the automation Postgres.
notificationsiduuid(pk)user_idtext, indexedtitletextdescriptiontextstatusINFO|WARN|ERRORkindtext, nullableautomation.run.completed.linktext, nullablesourcetext, nullableautomation:<uuid>,conversation:<uuid>.viewed_attimestamptz, nullableclient_tokentext, nullable, unique peruser_idcreated_attimestamptznow().Indexes:
(user_id, created_at desc),(user_id, viewed_at),(user_id, client_token).Status / severity
A three-level enum (
INFO | WARN | ERROR) supports severity thresholds("desktop-notify on
WARNand above") cleanly, and is more legible than an integerseverity at the call site.
kindstays orthogonal so the UI can pick icons / routeon event type without enum churn.
notification_preferencesuser_idtext(pk)desktop_enabledbooldesktop_min_severityINFO|WARN|ERRORupdated_attimestamptzSound on/off is not added here — reuse the existing
enable_sound_notificationssetting in agent-canvas (src/types/settings.ts,useAgentNotification).REST API
All under
/api/notifications/v1. Auth: sameX-API-Key(or session cookie) schemealready used by
/api/automation/v1.POST/notifications{title, description, status, kind?, link?, source?, client_token?}. Returns the row.GET/notifications?status=,?viewed=,?since=<ts>,?limit=.GET/notifications/{id}PATCH/notifications/{id}{viewed: true}.POST/notifications/mark-all-viewedDELETE/notifications/{id}DELETE/notifications{ids: [...]}or{before: <ts>}.GET/preferencesPATCH/preferencesRetention
Sweep in the existing
watchdog_loop:created_at.Both numbers are knobs in
config.py.Idempotency
If
client_tokenis supplied and a row with the same(user_id, client_token)exists,return the existing row instead of inserting. Lets automations retry safely.
Rate limiting
Soft per-user cap (e.g. 20/min). Above threshold: coalesce into a single notification
of the form "5 new notifications from
<source>" to keep desktop notifications sane.Hard cap (e.g. 100/min) returns
429.WebSocket
GET /api/notifications/v1/ws— auth viaSec-WebSocket-Protocolheader (token-in-URLis avoided because URLs leak into logs).
Server-side state:
dict[user_id, set[WebSocket]]. OnPOST /notifications:connections[user_id].Delivery semantics: at-most-once over WS. The DB row is the source of truth.
On reconnect, the frontend reconciles via
GET /notifications?since=<cursor>ratherthan relying on the WS to replay.
Heartbeats: server sends
{type: "ping"}every 30s; clients reply{type: "pong"}.Idle sockets get dropped after 90s.
Frontend integration (agent-canvas)
Discovery / connection
notificationstoVITE_RUNTIME_SERVICES_INFOand to the<RUNTIME_SERVICES>system-message-suffix block (seescripts/dev-safe.mjsand
src/api/agent-server-adapter.ts). The block already documents theautomation service; this slots in next to it.
useNotificationsSocket()hook that opens a WS to/api/notifications/v1/ws, reconciles on connect, and exposes incomingnotifications via a Zustand/Jotai store (whatever matches the existing
pattern — likely TanStack Query cache + a tiny event emitter).
Toast bridge
Every incoming WS notification fires
displaySuccessToast/displayErrorToast(or a newdisplayWarnToast) fromsrc/utils/custom-toast-handlers.tsx. Toast fires in every open tab.Desktop notification + multi-tab dedup
navigator.lockswithifAvailable: truegives leader election for free; only thetab that grabs the lock fires the OS notification.
tag: n.idis a backstop so theOS itself collapses any race-condition duplicates.
Toast fires unconditionally in every tab — it's harmless and the user reading a
different tab still needs to know.
Notification center UI
A new "bell" icon in the sidebar / header opens a panel listing recent notifications
(unviewed first), with mark-viewed and delete. Clicking a notification with a
linknavigates there and marks viewed.
Settings UI
Add a "Notifications" section to the existing App Settings page (sits next to
enable_sound_notifications):for the three possible states (
granted/denied/default).Discovery for agents
Two complementary paths, both should ship in v1:
notificationsskill inOpenHands/extensionswrapping the REST call:Reusable from any conversation, not just automation-generated ones.
Inject a helper into the preset boilerplate (prompt + plugin presets in
openhands/automation/presets/). The generated entrypoint already pulls theuser's API key and host — add a
notify_user(title, description, status="INFO")helper alongside it so preset prompts can call it without extra setup.
The system-suffix entry (above) documents the raw REST shape so even agents without
the skill can
curlit directly.Cross-repo work
OpenHands/automationnotifications/module (router, WS manager, retention sweep), preset boilerplate helper, ingress mount at/api/notifications/*.OpenHands/agent-canvasVITE_RUNTIME_SERVICES_INFOentry + suffix renderer, WS hook, toast bridge, desktop-notification dedup, notification center UI, settings UI.OpenHands/extensionsnotificationsskill.Open questions
description? Plain text is safer for v1; rendering markdown inboth the toast and the OS notification is fiddly.
work? The Web Notifications API doesn't auto-suppress for visible tabs on every
OS. My instinct: suppress only when
document.visibilityState === "visible"andthe user has opted in to that behavior.
tagfor grouping bysourceso multiple notifications from the same automation collapse? Or strict-per-id?
notifications API? Automations have a clean per-user API key path; conversations
need either the same key surfaced via
<RUNTIME_SERVICES>or a per-conversationshort-lived token. Likely fine to defer the conversation case until after
automations are wired.
Out of scope for v1 (recorded explicitly)
This issue was drafted by an AI agent (OpenHands) on behalf of @tofarr after a design discussion. Recommendations are opinionated starting points — push back where appropriate.