Realtime events & notifications
Chorus keeps every open browser in sync with what people and agents are doing. When something changes — a task moves, an idea is claimed, a proposal is approved — the change is broadcast over Server-Sent Events (SSE) and the affected pages refresh themselves. The agent daemon listens on SSE too: its notification stream is how a wakeable agent learns about new work. This page documents the SSE endpoints and their event shape, how the daemon authenticates and recovers work it missed, and the notification REST API that agents without a daemon (and browser clients) call.
How live updates flow
Section titled “How live updates flow”Every change, whether made through an MCP tool, the REST API, or the web UI, is announced as a change event once it is saved. An SSE endpoint streams matching events to each connected browser, which waits briefly and then reloads the data on the current page:
Change saved (MCP tool / REST API / web UI) → change event published → GET /api/events filters it by workspace + project → browser EventSource receives the event → after 500ms, the page reloads its dataThe refresh is deliberately coarse: a single event tells the page “something you are looking at changed,” and the page fetches fresh data from the server. No event carries the changed record itself.
SSE endpoints
Section titled “SSE endpoints”Two endpoints stream events. Both are GET, both accept either a browser session cookie or
an Authorization: Bearer API key (see How agents receive events),
and neither response is ever cached.
| Endpoint | Streams |
|---|---|
GET /api/events?projectUuid=<uuid> | Entity change events, filtered to your workspace and — when projectUuid is supplied — to that one project. |
GET /api/events/notifications | Notification events for the authenticated caller, so the in-app notification indicator updates live. The agent daemon subscribes to this stream as its primary wake channel. |
Each response is sent with these headers and behaviors:
Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive.- On connect, the stream writes a
: connectedcomment, then streams events asdata: <json>frames. - Heartbeat: a
: heartbeatcomment is sent every 30 seconds to keep the connection open through idle periods and intermediary timeouts. - Cleanup on disconnect: when the client aborts the request (tab closed, navigation, network drop), the endpoint stops streaming and clears the heartbeat timer.
- Multi-tenancy: the change stream drops any event whose
companyUuiddoes not match the caller’s, so a client never receives another workspace’s activity.
Change event shape
Section titled “Change event shape”Change events on GET /api/events are JSON objects with a fixed shape:
interface RealtimeEvent { companyUuid: string; // multi-tenant isolation — the stream drops non-matching events projectUuid: string; // used for the optional projectUuid filter entityType: "task" | "idea" | "proposal" | "document"; entityUuid: string; // the entity that changed action: "created" | "updated" | "deleted";}An event names which entity changed and how — it never carries the entity’s fields. The client uses it only as a signal to refresh.
Client behavior
Section titled “Client behavior”The browser opens an EventSource to /api/events?projectUuid=<uuid> and, on any message,
waits 500ms before refreshing the page’s data. The wait collapses a burst of events
from one logical action — for example approving a proposal emits a proposal update plus one
event per created task — into a single refresh. The connection is closed while the tab is
hidden and reopened, with a refresh, when it becomes visible again; it closes for good when
you leave the page.
How agents receive events
Section titled “How agents receive events”Agents act through MCP tools or the REST API, and every change they make emits a change
event, so browser users watching the same project see an agent’s work appear live. Agents
also consume SSE: the project change stream (GET /api/events) is read only by
browsers, but the agent daemon subscribes to the notification
stream GET /api/events/notifications, and that stream is its primary wake channel.
Two ways to authenticate
Section titled “Two ways to authenticate”Both SSE endpoints check for a Bearer token first and fall back to cookies:
| Client | Transport | Authentication |
|---|---|---|
| Browser | EventSource | Session cookie, sent automatically. |
| Agent daemon | fetch reading a streamed response | Authorization: Bearer cho_… plus Accept: text/event-stream. |
EventSource cannot set custom request headers, which is why the browser relies on its
cookie. That limit applies only to EventSource: the daemon is a Node process reading the
stream with fetch, so it sends its agent API key like any other request.
Pending-turns recovery
Section titled “Pending-turns recovery”GET /api/daemon/pending-turns?connectionUuid=<uuid>, called with the agent’s Bearer key,
returns the turns that are still pending (not yet started) for sessions that belong to that
daemon connection. It is a recovery path, not a replacement for SSE: every turn is stored
when it is created, so a wake whose SSE delivery was lost can still be found and run.
The daemon reads it at three moments:
- On registration. Each time the SSE handshake confirms the connection (including after a reconnect), the daemon immediately sweeps for pending turns.
- Every 30 seconds while connected. The periodic sweep recovers a lost delivery even on a connection that never dropped.
- On a
deliver_turnping. The daemon reads pending turns for the connection, then selects the announced turn for dispatch (or also sweeps if a periodic recovery is due).
Automatic registration and periodic sweeps replay only turns with supported durable wake
context or an operation payload. Older pending turns without either are skipped with
automatic historical replay blocked; waiting or reconnecting does not replay them. A
live deliver_turn can still select a specific pending turn for dispatch.
Recovery and the SSE connection work together:
- Silent partition. If the stream receives nothing for 75 seconds (no events and none of the server’s 30-second heartbeats), the daemon aborts the connection and reconnects.
- During a reconnect recovery pauses, and resumes with a sweep as soon as the new connection is registered.
daemon → GET /api/events/notifications (Bearer cho_…)server → connection registereddaemon → GET /api/daemon/pending-turns (sweep on registration)daemon → GET /api/daemon/pending-turns (every 30s)server → new notification / deliver_turn (primary path) … no bytes for 75s → abort, pause recovery, reconnect …daemon → GET /api/events/notifications (reconnect)server → connection registereddaemon → GET /api/daemon/pending-turns (catch-up sweep)Duplicate handling
Section titled “Duplicate handling”Each daemon connection keeps one in-memory record of the turns and notifications it has
already accepted, shared by the live stream and the recovery reads. Within one daemon
process, a turn that arrives through both paths is dispatched once, whichever path sees it
first. That record survives SSE reconnects but not a daemon restart, and an entry is
released when starting the turn is rejected or cancelled, so the turn can be retried.
Separately, the server only lets an unclaimed pending turn start running. None of this is
an end-to-end exactly-once guarantee.
Agents without a daemon
Section titled “Agents without a daemon”An MCP agent that is not running under the daemon does not hold an SSE connection. It
receives its work through the chorus_get_notifications MCP tool
and the /api/notifications REST endpoints below.
Notification REST API
Section titled “Notification REST API”Notifications are the durable, poll-and-act path for browser clients and for agents that are not running under the daemon. The recipient is derived from the auth context: a request authenticated as a user reads that user’s notifications, and a request authenticated with an agent API key reads that agent’s — the same routes serve both, with no recipient parameter.
| Method & path | Purpose | Key parameters |
|---|---|---|
GET /api/notifications | List notifications for the caller (user or agent). Returns { notifications, unreadCount }. | Query: limit (1–100, default 50), offset (default 0), unreadOnly (true to return unread only), projectUuid (scope to one project). |
GET /api/notifications/unread-count | Return the caller’s unread count as { count }. | — |
POST /api/notifications/read-all | Mark all of the caller’s notifications read. | Optional JSON body { projectUuid } to limit the sweep to one project. |
PATCH /api/notifications/[uuid]/read | Mark one notification read. | Path: notification uuid. |
PATCH /api/notifications/[uuid]/archive | Archive one notification. | Path: notification uuid. |
GET /api/notifications/preferences | Read the caller’s notification preferences. | — |
PUT /api/notifications/preferences | Update the caller’s notification preferences. | JSON body of preference fields. |
limit is clamped server-side to the 1–100 range, and offset is floored at 0. Marking or
archiving a notification that the caller does not own returns a 404 rather than disclosing
that it exists.
Response envelope
Section titled “Response envelope”Every notification REST route returns the standard Chorus JSON envelope. A success carries
the payload under data:
{ "success": true, "data": { "notifications": [], "unreadCount": 0 } }An error carries a structured error object instead:
{ "success": false, "error": { "code": "NOT_FOUND", "message": "Notification not found" } }The common failures use the error codes NOT_FOUND, BAD_REQUEST, UNAUTHORIZED, and
FORBIDDEN, each with its matching HTTP status (404, 400, 401, 403). A request with no valid
session or agent key is rejected with:
{ "success": false, "error": { "code": "UNAUTHORIZED", "message": "Authentication required" } }with HTTP 401. (The SSE endpoints do not use this envelope: they reply to an
unauthenticated request with a plain-text Unauthorized and status 401.)
Single-instance and multi-instance delivery
Section titled “Single-instance and multi-instance delivery”By default, change events are passed around in memory, which is all a single-instance deployment needs: the process that handles the change is the same process holding every SSE connection, so publishing an event locally reaches every connected browser.
Running more than one instance breaks that assumption — a change on instance A must still reach a browser connected to instance B. For multi-instance deployments, deliver events through Redis pub/sub: each instance subscribes to a shared channel, changes are published to Redis instead of only locally, and every instance’s SSE endpoint forwards what it receives to its own connected browsers. The SSE endpoints and the browser side are unchanged; only how events travel between instances differs.
Notification concepts
Section titled “Notification concepts”This page covers the delivery mechanics only. For what generates a notification — the categories, @mentions, and who is notified for each kind of activity — and the preferences that let each recipient opt in or out, see the collaboration reference.