# Realtime

Snakkbar pushes small **doorbell** events; you fetch the actual content over HTTP. That keeps the
stream cheap, makes missed events harmless (just refetch), and means one code path for initial
load and updates.

## Streams

| Who | Endpoint | Receives |
| --- | --- | --- |
| Visitor | `GET /api/v1/visitor/stream?conversationId=<id>` | Events for the visitor's own conversations; `typing`/`read` for `conversationId`; `presence`; `watch` for this visitor |
| Operator | `GET /api/v1/operator/stream` | Everything in the tenant, including live visitors (`visitor`, `pointer`) |

Both are Server-Sent Events over a normal `fetch` with the `Authorization` header (the browser's
`EventSource` can't send headers, so read `response.body` yourself or use the SDK).

```
event: ready
data: {"type":"ready"}

event: message
data: {"type":"message","conversationId":"…","messageId":4812}

event: typing
data: {"type":"typing","conversationId":"…","side":"operator","operator":{"id":"…","handle":"kari","name":"Kari","avatarUrl":null},"typing":true}
```

## Events (`RealtimeEvent`)

| `type` | Fields | Do |
| --- | --- | --- |
| `ready` | | Stream is live. Refetch once to cover anything missed while connecting. |
| `ping` | | Every 20 s. Ignore. |
| `conversation` | `conversationId` | Status, owner, participants or read state changed → refetch it (and your list). |
| `message` | `conversationId`, `messageId` | New message or a new translation → refetch the conversation. |
| `typing` | `conversationId`, `side`, `operator?`, `typing` | Show/hide a typing indicator. Hide it yourself after 6 s without updates. |
| `read` | `conversationId`, `side`, `at` | Update «Sett» receipts. |
| `presence` | | Someone checked in/out or came online → refetch `config()` (visitor) or `team()` (operator). |
| `visitor` | `visitorId` | Operator stream. A visitor came online, started a session, opened a page, switched tabs or changed their profile → refetch the visitor list (and that visitor's detail if shown). |
| `pointer` | **LiveFrame**: `visitorId`, `tabId`, `url`, `title`, `visible`, `viewport`, `scroll`, `documentHeight`, `samples`, `clicks` | Operator stream, only while someone watches that visitor. Draw it in the live view. The one event that carries data rather than a doorbell. |
| `watch` | `active` | Visitor stream. An operator started (`true`) or stopped (`false`) watching this visitor → switch the tracker's pace now. The SDK does this for you. |

Debounce refetches (the stock UIs use 250 ms) — one visitor message can ring several times
(message, translation, conversation update).

## Live visitors

The visitor's browser (the SDK's `startTracking()`, used by the stock widget) drives the live
visitor feed with `POST /visitor/activity`:

| | Cadence |
| --- | --- |
| Heartbeat | About every 10 s (the response's `interval`), from every open tab, plus at once on a page load, a single-page-app URL change or a tab switch. |
| Online | A heartbeat within the last **35 s**. Nothing rings when a visitor goes offline: re-check `lastSeenAt` on a timer. |
| Session | Starts with a heartbeat after 30 minutes of silence. |
| Watch | `POST /operator/visitors/:id/watch` lasts **25 s**; renew it every **10 s** (the SDK's `visitors.watch()` does). `DELETE` ends it at once. |
| While watched | Each tab sends a batch about every **500 ms** (pointer positions sampled at most every 40 ms, up to 100 per batch, and up to 20 clicks), or a keep-alive every 2 s when nothing moved. Every batch becomes one `pointer` event. |

The browser learns about a watch from the `watch` event if it has the visitor stream open (the
widget does while the chat is open), otherwise from the next heartbeat response
(`{ watch: true }`), so a live view can take up to 10 s to start. It falls back to heartbeats when
a response says `watch: false`, e.g. 25 s after the last renewal.

Using a `pointer` event: `samples` and `clicks` are `[t, x, y]`, where `t` is milliseconds since
the batch's first sample and `x`/`y` are viewport (client) coordinates in CSS px; replay them over
the batch for smooth motion. Scale them by your view's width divided by `viewport.width`, and
scroll your copy of the page to `scroll`. Several tabs of one visitor can send frames; follow the
one with `visible: true`. `pointer` events go to every operator's stream, so filter by `visitorId`.

## Lifetime and fallback

The server ends each stream after about 4 minutes; reconnect immediately. On errors, back off
(1 s, 2 s, 5 s). If streaming keeps failing (some proxies buffer SSE), poll the relevant `GET`
every 5 s and try streaming again every 60 s. The SDK's `subscribe()` does all of this and, while
polling, emits `conversation` events — with `conversationId: "*"` on the operator side, meaning
"refetch everything". Polling can't carry `pointer` events: a live view only moves while the
operator stream is live (`onStatus` reports `"live"`).

## Turn streams

Sending a visitor message (`POST /visitor/conversations/:id/messages`) returns its own SSE stream
with the assistant's reply as it is written (`TurnEvent`s, see [api.md](api.md)). Doorbell events
for the same messages also arrive on the realtime stream; dedupe by message `id` (or `clientId`).
