Skip to content

Consuming live events (SSE)

GET /api/live/events is a Server-Sent Events stream of every state change in one company. Hold the connection open, react in real time. Use it when polling isn't fast enough and you don't want to write a worker.

Source: internal/sse/hub.go (the hub and its Handler).

Quick start

const es = new EventSource(
  '/api/live/events?Authorization=' + encodeURIComponent('Bearer ' + token)
);
es.addEventListener('issue_updated', (ev) => {
  console.log('issue updated:', ev.data);   // bare issue ID
});
es.addEventListener('approval_created', (ev) => {
  console.log('new approval:', ev.data);    // bare approval ID
});
es.addEventListener('run.started', (ev) => {
  console.log('run started:', JSON.parse(ev.data));  // JSON object
});
es.onerror = () => console.warn('SSE dropped; EventSource auto-reconnects');

Any HTTP/1.1 client that handles text/event-stream works. curl -N is handy for ad-hoc inspection:

curl -N -H "Authorization: Bearer $TOKEN" "$BASE/api/live/events"

Auth

Two ways, both resolving to the same actor:

  • Authorization: Bearer sk_… header — preferred for non-browser clients.
  • Authorization query parameter — for browser EventSource, which can't set headers. URL-encode the full Bearer sk_… string.

The auth context is fixed for the connection's lifetime. To switch identity, close and reconnect. The stream is scoped to the actor's company; you only see that company's events. (Instance admins may target a specific company with ?company_id=<uuid>; otherwise it defaults to the actor's company.)

Events you can subscribe to

Every event the hub publishes. The data line is either a bare ID or a JSON object — the table says which.

Event type data Fired when
issue_created issue ID A new issue (manual, /create-issue, or a routine)
issue_updated issue ID Any change to an issue (status, priority, assignee, comment, …)
activity_created issue ID An activity-log row is added
approval_created approval ID An agent emits /request-approval
approval_resolved approval ID A human approves/rejects
agent_status_changed agent ID Pause/resume or heartbeat state change
heartbeat_started run ID A heartbeat run begins
heartbeat_completed run ID A heartbeat run finishes
heartbeat_log run ID A streaming log line was captured for a run
run.started {runId, agentId, issueId, ts} (JSON) Same moment as heartbeat_started, with context for dashboards
run.status JSON object A run's status changed
run.log JSON object A run emitted a transcript event
run.cancelled JSON object A run was cancelled (watchdog or operator)
run.stale / run.grace JSON object The stale-run watcher flagged / grace-windowed a run
chat_created / chat_updated / chat_deleted chat ID Chat lifecycle
chat_message_created chat ID A message was appended to a chat
note_created / note_updated / note_archived / note_unarchived / note_promoted / note_deleted note ID Note lifecycle
knowledge_created / knowledge_updated / knowledge_archived knowledge doc ID Knowledge Base document lifecycle (#91)
council_convened council session ID A council is convened for an issue (#263)

run.* events carry JSON objects; the legacy heartbeat_* events carry bare IDs and coexist with the newer run.* events for the same moments. Treat unknown event types as forward-compatible — Trimus adds new ones without removing old ones.

In split deployments these live events now cross tiers automatically (#237, via Postgres LISTEN/NOTIFY), so a browser connected to any web pod receives scheduler-generated events too — you don't have to pin the stream to the tier that produced the event.

Wire format

Standard SSE. Each event is:

event: issue_updated
id: 4217
data: 1f25aaee-7c1e-4b2a-9f0e-2b3c4d5e6f70
  • event: — the event type from the table above.
  • id: — a monotonic per-company sequence number (seq). Browsers persist it automatically and resend it as Last-Event-ID on reconnect. (Events published before seq existed omit the id: line — harmless.)
  • data: — a bare ID or a JSON object (see the table).
  • A blank line terminates the event.

Two synthetic frames the server emits that aren't domain events:

event: connected
id: 4217
data: {"current_seq":4217}

Sent right after you connect (and after any replay), so you know the high-water seq even before the first live event.

event: gap
data: {"requested":1000,"oldest_available":3500}

Sent when you reconnect with a Last-Event-ID older than the replay buffer can reach — the events between requested and oldest_available are gone. Recover by re-reading state from the JSON API.

The server also writes an SSE comment-line ping every 5 seconds to keep the connection alive and detect disconnects.

Reconnection, replay, and ordering

Replay buffer (no lost events on brief drops)

The hub keeps a per-company ring buffer of recent events (about 1000, roughly 60–90 s of busy activity). When you reconnect with Last-Event-ID: N (browsers do this automatically; custom clients set the header), the server replays every buffered event with seq > N before resuming the live stream. So a laptop-sleep or a 3-second EventSource reconnect loses nothing.

If your gap is longer than the buffer, you get an event: gap frame (above) and must reconcile from the API.

Custom-client reconnect loop

last_id = None
failures = 0
while True:
    headers = {"Authorization": f"Bearer {token}"}
    if last_id is not None:
        headers["Last-Event-ID"] = str(last_id)
    try:
        for event in stream(url, headers=headers):
            if event.id:
                last_id = event.id          # track for resume
            handle(event)
        failures = 0
    except StreamError:
        time.sleep(min(60, 1.5 ** failures))
        failures += 1

Track the id: you last saw and resend it as Last-Event-ID so the server can replay the gap.

Ordering

Events arrive in publish order per company. Within one heartbeat run, events emit in the order the engine applied them. Across simultaneous runs, interleaving is expected — order globally by seq if you need a total order.

Per-run replay endpoint

For the transcript of a specific heartbeat run, there's a dedicated replay endpoint with explicit gap recovery:

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/runs/$RUN_ID/events?since=0&limit=500"

Response: {runId, events:[{seq,kind,payload,created_at}], nextSeq, runStatus}. Page forward by passing the previous response's nextSeq back as since. limit defaults to 500, max 2000. This is the loss-free way to read a run's full transcript; the live /api/live/events run.log/heartbeat_log events are the real-time tail of the same data.

The chat per-token stream is a different thing

Don't confuse /api/live/events (the company event hub) with the chat token streaming endpoints, which use text/event-stream response bodies on a single POST rather than the shared hub:

  • POST /api/chats/{chatId}/messages/stream — the REPL/JSON variant. Emits event: chunk ({"text":"…"}), then event: done ({"messageId",…token counts}), or event: error on failure.
  • POST /chats/{chatId}/ui/send-stream — the browser-UI variant. Emits user_message, agent_message_start, delta, agent_message_complete.

These are request-scoped streams for one chat reply, not the subscribe-to-everything hub. Use the hub for "react to events"; use the chat stream endpoints only when you're driving a chat directly.

Reverse-proxy gotcha

SSE connections are long-lived. Most proxies have an idle read timeout (often 60 s) that kills them — symptom: the connection drops every minute on the dot. The server already sets X-Accel-Buffering: no and Cache-Control: no-cache; you still need to disable buffering and raise the read timeout on the proxy:

location /api/live/events {
  proxy_pass http://trimus;
  proxy_http_version 1.1;
  proxy_buffering off;
  proxy_read_timeout 1d;
  proxy_set_header Connection '';
}

Caddy, ALB, and Cloudflare have equivalent settings. Without them, you're effectively polling once a minute via reconnects.

Revocation gotcha

If a token is revoked while a connection is open, the existing stream keeps running — it authenticated at connect time. Reconnects then fail with 401. (One exception: if the server has SSE membership re-verification enabled, a user whose company membership is revoked mid-stream is dropped within ~60 s. Agent/board-user key connections stay up for their lifetime.)

What you can't do via SSE

  • Acknowledge events. SSE is fire-and-forget. If you need at-least-once semantics on work items, build a worker (building-a-worker.md).
  • Filter at the server. All of a company's events come through one stream; filter client-side.
  • Subscribe across companies. One company per connection; open several streams for several companies.

Where to next