Skip to content

Signals SSE

GET /events/stream is the canonical durable stream of body-free invalidations for Signals, tasks, Inbox receipts, and audience changes. Clients reload the affected bounded projection after an invalidation. GET /signals/stream remains the signal-only compatibility view over that same ordered ledger and carries authorized Signal snapshots. Events have durable opaque cursors, so a client can reconnect to another replica or after a server restart without a process-local replay gap.

Method + path Purpose Auth
POST /signals/stream/tickets Mint a short-lived, one-use browser ticket Bearer member
GET /signals/stream Open or resume the SSE stream Bearer or one-use ticket

Node, Bun, server-side runtimes, and command-line clients should send the API key in the Authorization: Bearer … header on GET /signals/stream.

Browser clients first mint a ticket, then put only that one-use capability in the stream URL:

Terminal window
TICKET=$(curl -s -X POST https://<host>/signals/stream/tickets \
-H "Authorization: Bearer $CHORUS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scope":"/signals/stream"}' | jq -r .ticket)
curl -N "https://<host>/signals/stream?ticket=$TICKET"

Tickets expire after 60 seconds, are bound to the issuing identity, API key, and stream scope, and are consumed atomically by the first connection. Chorus stores only their SHA-256 digests. Revoking the API key invalidates an unused ticket because connection auth rechecks the live identity and key records.

The TypeScript SDK chooses the safe path automatically: browser runtimes mint a fresh ticket for every connection, while Node and Bun use bearer fetch. Both parse the same stream and reconnect with the last durable cursor.

The canonical stream uses the default SSE message event and a body-free envelope:

event: message
id: sse1_2s_f4c9…
data: {"version":"chorus.change-stream/1","event_kind":"task","resource_ref":"task:…","change_type":"updated","occurred_at":"…"}

The legacy Signal stream keeps its snapshot envelope:

event: message
id: sse1_2s_f4c9…
data: {"version":"chorus.signal-stream/1","signal_type":"task","signal":{"id":"signal:…","signal_type":"task","content":"…"}}
  • event is always message; clients dispatch typed handlers from event_kind on /events/stream or signal_type on /signals/stream.
  • id is an opaque durable cursor, not signal.id or the legacy constant signal sequence.
  • Canonical data never includes Signal or task bodies. Legacy data.signal is the complete authorized Signal snapshot.
  • : keepalive comments hold idle connections open without creating an event.

All filters are optional:

Param Form Effect
ring comma-separated names/IDs Signals targeting one of these visible rings
type comma-separated strings Matching signal types
min_urgency number signal.urgency >= min_urgency
tags comma-separated strings At least one tag overlaps
role role name/ID Signals sent from or targeting that role

Visibility uses inbox-equivalent targeting. A non-admin sees a signal only when its ring is visible under membership and API-key scope and any direct identity/role target addresses the caller or a role the caller currently fills. Unaddressed threaded replies do not become broadcasts. Filters can only narrow that authorized result. Once authorized, lifecycle snapshots remain visible even when their delivery state becomes terminal.

Each signal creation transaction appends a monotonically ordered stream-event row alongside the signal and webhook outbox entry. Claim, extension, acknowledgement, cancellation, and claim-admission compensation append their resulting snapshots in the same transaction as the state transition. On connection, the server subscribes to wake hints, captures the durable high-water mark, catches up through it, and then continues reading ordered rows. The process-local signal bus never carries authoritative stream bytes.

Task acknowledgement is idempotent for an exact same-holder, same-generation retry. Chorus returns the durable acknowledged snapshot only after configured completion hooks succeed; stale generations and foreign holders still fail closed.

Resume an existing live view by sending its last cursor in Last-Event-ID:

Terminal window
curl -N https://<host>/signals/stream \
-H "Authorization: Bearer $CHORUS_API_KEY" \
-H "Last-Event-ID: sse1_2s_f4c9…"

Chorus never turns an unusable cursor into an empty success:

Status Code Meaning
409 STREAM_CURSOR_EXPIRED Cursor fell outside retention; reset/reconcile required
409 STREAM_CURSOR_UNKNOWN Cursor is malformed or was never issued
403 STREAM_CURSOR_FOREIGN Cursor exists but is outside the caller’s visibility

If retention expires a required row after the HTTP stream has already opened, Chorus emits an SSE error event with STREAM_CURSOR_EXPIRED and reset_required: true, then closes.

The durable stream retention window is 10 minutes. To establish a new snapshot/live view—or recover after any reset—first call authenticated POST /signals/stream/checkpoint, then load the complete inbox/search snapshot, and finally open the stream with the checkpoint cursor in Last-Event-ID. This ordering makes events committed during snapshot loading replay into the live view. Never recover by opening a no-cursor stream after a snapshot: its handshake high-water can skip the cutover event.

  • SSE_MAX_STREAMS_PER_IDENTITY defaults to 5 per replica; admins are exempt.
  • Catch-up reads are bounded and the server permits at most SSE_MAX_PENDING_EVENTS (default 2048) between the consumer and durable high-water mark.
  • A slow reader receives STREAM_SLOW_CONSUMER with reset_required: true, then the stream closes instead of growing memory without bound.
  • The SDK iterator queue is also bounded (default 256). Client overflow is terminal and reports CLIENT_STREAM_QUEUE_OVERFLOW with the last safely accepted cursor.
  • Server drain and forced shutdown own active stream abort handles; client close() and AbortSignal cancel the reader, reconnect timer, and pending iterators idempotently.

The concurrent-stream counter is deliberately replica-local admission control. Durable events and tickets are database-backed and therefore do not share that limitation.