> ## Documentation Index
> Fetch the complete documentation index at: https://docs.erdo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Realtime updates

> Refresh an external app when records, conversations, attention, and approvals change

# Realtime updates

Erdo's organization WebSocket tells an external app when its canonical data has
changed. Events are small invalidation signals rather than copies of datasets or
messages: when one arrives, re-read the affected view through `/v1` so current
permissions, dataset filters, and response contracts still apply.

## Keep the API key on your server

The browser never needs your Erdo API key. Add a same-origin route to your app's
server that calls the ticket endpoint with the browser application's exact origin:

```bash theme={null}
curl -X POST https://api.erdo.ai/v1/realtime/tickets \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "X-Organization-ID: acme" \
  -H "Content-Type: application/json" \
  -d '{"origin":"https://app.example.com"}'
```

```json theme={null}
{
  "ticket": "one-time-ticket",
  "organization_id": "organization-uuid",
  "expires_in_seconds": 300
}
```

The active organization comes from normal API authentication, including an
optional `X-Organization-ID` header. A caller cannot request a ticket for a
different channel or organization. The ticket expires after five minutes, is
consumed by the first successful connection, and accepts only the exact origin
supplied above.

## Connect and subscribe

Return the ticket—not the API key—from your server route to your browser. Convert
the API base URL to WebSocket protocol and include the authorized organization in
the connection query:

```js theme={null}
const bootstrap = await fetch("/api/erdo-realtime", { method: "POST" }).then(
  (response) => response.json(),
)

const url = new URL("https://api.erdo.ai/websocket/connect")
url.protocol = "wss:"
url.searchParams.set("ticket", bootstrap.ticket)
url.searchParams.set("resource_type", "organization")
url.searchParams.set("resource_id", bootstrap.organization_id)

const socket = new WebSocket(url)
socket.addEventListener("open", () => {
  socket.send(JSON.stringify({
    type: "subscribe",
    channel: {
      type: "organization",
      resource_id: bootstrap.organization_id,
    },
  }))
})
```

Wait for the `subscribed` control message before showing a live status. A ticket is
one-time, so request a fresh ticket before every reconnect. Use exponential backoff
and keep a visibility-aware periodic refresh as recovery for missed events. While
the socket is open and the tab is visible, send `{"type":"ping"}` every 30 seconds;
Erdo answers with `type: "pong"` and keeps the connection alive.

## Handle events

Broadcast messages have a top-level `type`, `channel`, and `timestamp`. `payload`
is a JSON-encoded string whose fields depend on the event type:

```js theme={null}
socket.addEventListener("message", (message) => {
  const event = JSON.parse(message.data)

  if (event.type === "subscribed") return

  switch (event.type) {
    case "record_captured":
    case "thread_activity":
    case "attention_created":
    case "attention_updated":
    case "approval_created":
    case "approval_decided":
    case "entity_autolink_result":
      refreshVisibleData()
      break
    default:
      // Forward compatibility: ignore event types this client does not know.
      break
  }
})
```

`record_captured` is emitted only after a successful record-capture pipeline run.
It names the pipeline, execution, and written dataset references but never includes
the submitted record. Page views and widget telemetry do not emit this event, so
normal traffic cannot create an app-wide refresh loop. `thread_activity` likewise
contains conversation identity and activity type, not message content.

The WebSocket is a prompt to re-read, not a durable event log. Debounce bursts of
events, refresh only while the tab is visible, reconnect with a new ticket after a
disconnect, and retain a slower periodic refresh so the UI converges even if a
producer has no realtime event yet.
