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

# WebSocket Integration

> Connect a browser or client directly to a chat session for live updates

The Chat v1 REST endpoints cover request/response integrations — a gateway sending customer messages and reading replies. For a live UI (a web widget, an embedded support panel) that needs to show messages, mode changes, and session events as they happen, connect directly to the session over a WebSocket instead of polling.

<Note>
  This is for connecting a client — typically a browser — to one specific session, in addition to your server-to-server integration with `POST /v1/chat/inbound`. It's not a replacement for the REST API.
</Note>

## 1. Mint a token

Call [`POST /v1/chat/session/{id}/ws_token`](/api-reference/endpoint/chat/ws_token) with your `API-Token` to get a short-lived token and the URL to connect to:

```json theme={null}
{
  "stream": "user"
}
```

<Note>
  With an `API-Token`, `stream` must be `"user"` — a `403` is returned for any other value. The session must not be closed (`409 session_closed`); a dormant session can still be minted for, since the next customer message reactivates it.
</Note>

```json theme={null}
{
  "success": true,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "ws_url": "wss://<host>/v1/chat/session/4718a326-417b-4dda-90d0-21fd65a11cb8/user-stream",
    "expires_at": 1789191300,
    "stream": "user"
  }
}
```

The token expires 5 minutes after it's issued. Mint a fresh one for each new connection, and again before an existing connection's token expires if you want to keep the connection alive uninterrupted (see [Reconnecting](#reconnecting)).

## 2. Connect

Open the WebSocket at `ws_url` exactly as returned (don't build the host yourself), adding `token` and your own `client_id` as query parameters:

```
wss://<host>/v1/chat/session/4718a326-417b-4dda-90d0-21fd65a11cb8/user-stream?token=<token>&client_id=<your-id>
```

| Parameter | Required | Description |
| - | - | - |
| `token` | Yes | The token from step 1. |
| `client_id` | Recommended | Any stable string you generate to identify this browser tab / client instance. Only one `client_id` can hold a session's connection at a time — a second one connecting without `claim=1` is rejected so two tabs can't both act as the live client for the same session. |
| `claim` | No | Set to `1` to take over the connection from another `client_id` currently holding it (e.g. the user reopened the widget in a new tab and wants to resume there). Omit or set to `0` otherwise. |

## 3. Send frames

Send JSON text frames. Two shapes are accepted:

<CodeGroup>
  ```json Plain message theme={null}
  {
    "type": "message",
    "content": "Hi, I need help with my order",
    "message_id": "your-client-generated-id"
  }
  ```

  ```json Interactive reply theme={null}
  {
    "type": "interaction_response",
    "interaction_id": "int_7f2a9c",
    "option_id": "confirm"
  }
  ```
</CodeGroup>

* `type` defaults to `"message"` if omitted.
* `message_id` is optional — a client-chosen id echoed back on the acknowledgement, useful for matching a send to its ack in your UI.
* `idempotency_key` is also accepted on either frame shape and behaves the same as on [`POST /v1/chat/inbound/async`](/api-reference/endpoint/chat/inbound_async) — resending the same key and content is safe.
* For interaction responses, send `option_id` (a button/list/carousel choice) or `response` (free-form input like a date), matching what the interactive message asked for — see [Interactive messages](/api-reference/endpoint/chat/interactive_messages).
* Only text frames are accepted; sending binary data closes the connection.

## 4. Read acknowledgements and events

Every frame you send gets exactly one acknowledgement back:

```json theme={null}
{
  "type": "ws_write_result",
  "session_id": "4718a326-417b-4dda-90d0-21fd65a11cb8",
  "account_id": 123,
  "payload": { "state": "default", "reply": "Sure, what's your order number?", "mode": "ai", "help_requested": false, "turn_id": "turn_9f3d2a1b", "message_id": "your-client-generated-id" }
}
```

When your message completes a turn inline (the common case), `payload` carries the same fields and `state` values as the synchronous [Send Message](/api-reference/endpoint/chat/send_message) response — `reply`, `mode`, and `help_requested`. A few states are specific to the socket:

| State | Meaning |
| - | - |
| `accepted` | Your message replayed an already-accepted turn (e.g. a retried send with the same `idempotency_key`) rather than starting a new one. |
| `merged` | Your message was folded into another turn already in flight; `merged_into` names it. No separate reply for this frame — watch for a [`turn_result`](#events) event instead. |
| `busy` | The engine couldn't accept the turn right now (`error: "server_busy"`) — safe to retry. |
| `invalid` | The frame was rejected — see `error` (e.g. `bad_json`, `bad_payload`, `message_required`, `invalid_input_type`, `agent_inactive`, `session_account_mismatch`, `idempotency_key_reused`, `idempotency_key_rail_conflict`, `invalid_interaction`). |
| `error` | An unexpected failure processing the frame. |

Every ack echoes `message_id` back if your frame included one, and carries `turn_id` once a turn exists for it.

## Events

Beyond acknowledgements, the connection pushes events as they happen in the session:

| Event `type` | Fires when | Payload |
| - | - | - |
| `message_received` | A message (from the customer or the AI) is added to the session | `{ id, type: "message", role: "user" \| "ai", content, created_at }` |
| `turn_result` | A turn processed out-of-band of your own frame's ack reaches a result — this is how a reply to [`POST /v1/chat/inbound/async`](/api-reference/endpoint/chat/inbound_async), or a turn merged into another, reaches an open socket | `{ turn_id, message_id, state, merged_into, error }` |
| `mode_changed` | The session switches between AI and human-operator mode | `{ to: "ai" \| "support", active_human? }` |
| `session_closed` | The session is closed | `{}` |
| `session_dormant` | The session goes quiet after a period of inactivity | `{ reason: "inactivity" }` |
| `session_reactivated` | A dormant session picks back up | `{ reason }` |
| `nudge_sent` | The agent sends a proactive follow-up after the customer goes quiet | `{ idle_seconds }` |
| `auto_resume_fired` | The session automatically resumes AI handling after being nudged awake | `{ idle_seconds, reason }` |

Every event is wrapped as `{ type, session_id, account_id, ts, event_id, payload }`. Use `event_id` to de-duplicate — a reconnect can replay a short window of recent events so you don't miss anything that happened while briefly disconnected.

## Close codes

| Code | Meaning |
| - | - |
| `4401` | Authentication failed — the token is missing, expired, invalid, or doesn't match this session's account. Mint a new one and reconnect. |
| `1008` | Closed for policy reasons — the token's session/stream doesn't match this connection, the agent is inactive, or another client claimed the session (`client_id` conflict without `claim=1`). Don't blindly reconnect with the same token; on a claim conflict, only reconnect with `claim=1` if the user explicitly wants to take over. |
| `1003` | A non-text frame was sent. Send JSON text frames only. |
| `1011` | An unexpected server error. Safe to reconnect. |

## Reconnecting

* On an unexpected drop, reconnect with exponential backoff — roughly 1s, 2s, 4s, 8s, up to a 30s cap — and give up after a handful of attempts, surfacing an error in your UI.
* Mint a new token ahead of the 5-minute expiry rather than waiting for the connection to be closed, so an active conversation doesn't interrupt the customer.
* Reuse the same `client_id` across reconnects (persist it locally, e.g. in `sessionStorage`) so you resume as the same client. Only send `claim=1` when the user explicitly wants to take over from another tab/session holding the connection — not on every routine reconnect, or you'll evict yourself on the next tab.
* On a `1008` close with the agent inactive, don't reconnect immediately in a tight loop — poll less aggressively (e.g. every 20s) until the agent is active again. On a `1008` close from a claim conflict, don't auto-reconnect at all — surface a "Resume here?" action and only reconnect with `claim=1` if the customer confirms.
