Live events API
GET /api/projects/{projectId}/stream
Section titled “GET /api/projects/{projectId}/stream”A text/event-stream connection carrying one event per write in the project.
curl -N "$USHABTI_URL/api/projects/$PROJECT/stream" \ -H "Authorization: Bearer $USHABTI_TOKEN"retry: 3000
event: readydata: {}
event: changedata: {"projectId":"…","scope":"board","clientId":"tab-8f2c"}
: ping
event: changedata: {"projectId":"…","scope":"task","taskId":"…","clientId":"tab-8f2c"}Response headers: Content-Type: text/event-stream; charset=utf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no.
What it sends
Section titled “What it sends”| Line | Meaning |
|---|---|
retry: 3000 |
Sent once, first. The browser reconnects after three seconds. |
event: ready |
Sent once. The subscription is live — nothing more. |
event: change |
One per write. The payload is below. |
event: presence |
Which task a person’s tab has open: { projectId, kind: "presence", clientId, userId, taskId, field }, with taskId null when it closed. field names the box the tab is typing in — "title", "description" or "checklist:<itemId>" — and is null when it types in none. It carries its data because nothing stores it, and it is not a change: do not refetch on it. A client that does not draw faces ignores it. |
: ping |
A comment every 25 seconds, so proxies do not close the connection. EventSource ignores it. |
{ "projectId": "…", "scope": "board" | "task" | "project", "taskId": "…", // present on task-scoped events "clientId": "…" // the x-ushabti-client header of whoever wrote}What it does not send
Section titled “What it does not send”The change itself. The event names what changed, never how, so there is no payload to arrive out of order, no partial state to reconcile and no way for two clients to disagree about what happened.
A listener refetches:
scope: "board"→ refetch the boardscope: "task"with ataskId→ refetch that taskscope: "project"→ the project’s own settings changed
Consuming it
Section titled “Consuming it”const stream = new EventSource(`/api/projects/${projectId}/stream`)
stream.addEventListener('ready', () => { // Re-read the board on EVERY connect, not only the first. refetchBoard()})
stream.addEventListener('change', (event) => { const change = JSON.parse(event.data) if (change.clientId === MY_CLIENT_ID) return if (change.scope === 'task') refetchTask(change.taskId) else refetchBoard()})How it works underneath
Section titled “How it works underneath”A write calls pg_notify on a single channel. Every Ushabti process holds one LISTEN connection
and fans each notification out to the streams it is serving for that project. So two processes on
the same database stay in step with no message broker and nothing to run.
Which routes broadcast what
Section titled “Which routes broadcast what”| Route | scope |
|---|---|
| create a task · move a task · delete a task | board |
| write a value | board, with a taskId |
| create or change a property, an option or a view | board |
| edit a task · checklist · comments | task |
| project name, members, agents | project |
A PATCH that changes nothing broadcasts nothing.