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:
Auth¶
Two ways, both resolving to the same actor:
Authorization: Bearer sk_…header — preferred for non-browser clients.Authorizationquery parameter — for browserEventSource, which can't set headers. URL-encode the fullBearer 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:— the event type from the table above.id:— a monotonic per-company sequence number (seq). Browsers persist it automatically and resend it asLast-Event-IDon reconnect. (Events published before seq existed omit theid: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:
Sent right after you connect (and after any replay), so you know the high-water seq even before the first live event.
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:
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. Emitsevent: chunk({"text":"…"}), thenevent: done({"messageId",…token counts}), orevent: erroron failure.POST /chats/{chatId}/ui/send-stream— the browser-UI variant. Emitsuser_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¶
- api-recipes.md — follow-up reads after an event.
- building-a-worker.md — for ack-needed work.
- patterns.md — a worked Slack-pinger example.