Skip to content

Live events API

A text/event-stream connection carrying one event per write in the project.

Terminal window
curl -N "$USHABTI_URL/api/projects/$PROJECT/stream" \
-H "Authorization: Bearer $USHABTI_TOKEN"
what comes down the wire
retry: 3000
event: ready
data: {}
event: change
data: {"projectId":"…","scope":"board","clientId":"tab-8f2c"}
: ping
event: change
data: {"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.

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.
a change payload
{
"projectId": "…",
"scope": "board" | "task" | "project",
"taskId": "…", // present on task-scoped events
"clientId": "…" // the x-ushabti-client header of whoever wrote
}

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 board
  • scope: "task" with a taskId → refetch that task
  • scope: "project" → the project’s own settings changed
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()
})

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.

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.