# WebSocket reference

Realtime protocol v1 used by the dashboard for live feeds, the screencast and takeover input. Generated from `@browserhive/contracts/ws`. Available when `--admin` is on.

## Handshake

- URL: `ws://<host>:<port>/api/v1/ws` (same port as everything else).
- Subprotocol: `Sec-WebSocket-Protocol: browserhive.v1`.
- Authentication: the dashboard session cookie or `Authorization: Bearer <token>`, checked at upgrade. A failed check completes the upgrade and closes immediately with `4401`, so browsers see the code.
- The first server frame is a `reply` with payload `type: "hello"`, carrying `epoch` and the current `cursor`. `epoch` changes on every server start; a cursor from another epoch cannot be replayed.

## Envelope

Every server text frame is JSON:

```ts
{ v: 1, kind: "event" | "reply" | "error" | "stream", seq: number, ts: number, topic?: string, corr?: string, payload: unknown }
```

| kind | Meaning |
|---|---|
| `event` | ordered, replayable feed event on a topic (has `topic` and `seq`) |
| `reply` | answer to a client command (echoes `corr`); also the first `hello` frame |
| `error` | command failure or protocol violation; the socket stays open |
| `stream` | screencast control message on `screencast:<id>`; latest-wins, never replayed |

Client frames are JSON objects discriminated on `type` (see [client commands](#client-commands)); any command may carry `corr` (1–64 characters), which is echoed on its reply or error.

## Limits

| Limit | Value |
|---|---|
| `maxInboundFrameBytes` | 16KiB |
| `maxProtocolViolations` | 5 |
| `feedBufferCount` | 10000 |
| `feedBufferBytes` | 8MiB |
| `feedBufferMs` | 5m |
| `heartbeatMs` | 20s |
| `staleMs` | 1m |
| `tickMs` | 30s |
| `overloadBytes` | 4MiB |
| `overloadGraceMs` | 10s |
| `screencastDropBytes` | 1MiB |

## Close codes

| Code | Name | Meaning |
|---|---|---|
| 4401 | `UNAUTHORIZED` | not authenticated or the session expired; log in again |
| 4403 | `PASSWORD_CHANGE_REQUIRED` | the operator must change the password first |
| 4400 | `PROTOCOL_ERROR` | too many malformed frames |
| 4406 | `BAD_SUBPROTOCOL` | missing or unknown `Sec-WebSocket-Protocol` (expected `browserhive.v1`) |
| 1013 | `OVERLOADED` | the client did not read fast enough (backpressure limit exceeded) |
| 1001 | `GOING_AWAY` | server shutting down, or the socket was silent too long |

## Client commands

| type | Fields | Scope |
|---|---|---|
| `ping` | — | any authenticated caller |
| `subscribe` | `topic`: `string`, `cursor?`: `integer` | any authenticated caller |
| `unsubscribe` | `topic`: `string` | any authenticated caller |
| `screencast.start` | `session_id`: `string`, `max_width?`: `integer`, `max_height?`: `integer`, `quality?`: `integer` | `sessions:read` |
| `screencast.stop` | `session_id`: `string` | `sessions:read` |
| `screencast.set_size` | `session_id`: `string`, `max_width`: `integer`, `max_height`: `integer` | `sessions:read` |
| `input` | `session_id`: `string`, `input`: `object` | `sessions:takeover` |
| `session.set_viewport` | `session_id`: `string`, `width`: `integer`, `height`: `integer` | `sessions:write` |
| `logs.tail` | `level?`: one of `error`, `warn`, `info`, `debug`, `trace`, `module?`: `string` | `logs:read` |

`input` is accepted only while an attention request is open for the session and is re-checked on every message (`INPUT_NOT_PERMITTED` otherwise). `session.set_viewport` is an observability control and is not attention-gated.

### Takeover input (`LiveInput`)

Field names follow the Chrome DevTools Protocol (camelCase). `modifiers` is a bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8.

#### `mouse`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `type` | `"mouse"` | yes | — |
| `action` | one of `mouseMoved`, `mousePressed`, `mouseReleased`, `mouseWheel` | yes | — |
| `x` | `number` | yes | ≥ 0; ≤ 100000 |
| `y` | `number` | yes | ≥ 0; ≤ 100000 |
| `button` | one of `none`, `left`, `middle`, `right` | no | — |
| `clickCount` | `integer` | no | ≥ 0; ≤ 8 |
| `deltaX` | `number` | no | ≥ -10000; ≤ 10000 |
| `deltaY` | `number` | no | ≥ -10000; ≤ 10000 |
| `modifiers` | `integer` | no | ≥ 0; ≤ 15 |

#### `key`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `type` | `"key"` | yes | — |
| `action` | one of `keyDown`, `keyUp`, `char`, `rawKeyDown` | yes | — |
| `key` | `string` | no | max length 32 |
| `code` | `string` | no | max length 64 |
| `text` | `string` | no | max length 16 |
| `windowsVirtualKeyCode` | `integer` | no | ≥ 0; ≤ 255 |
| `modifiers` | `integer` | no | ≥ 0; ≤ 15 |

#### `touch`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `type` | `"touch"` | yes | — |
| `action` | one of `touchStart`, `touchEnd`, `touchMove`, `touchCancel` | yes | — |
| `points` | `object[]` | yes | at most 10 items; each item: keys `x`, `y`, `radiusX`, `radiusY`, `force`, `id` |
| `modifiers` | `integer` | no | ≥ 0; ≤ 15 |

## Replies

| payload type | Fields |
|---|---|
| `hello` | `protocol`: `"browserhive.v1"`, `protocol_version`: `1`, `epoch`: `string`, `cursor`: `integer`, `server_version`: `string`, `now`: `integer` |
| `subscribed` | `topic`: `string`, `from`: `integer`, `to`: `integer`, `complete`: `boolean` |
| `unsubscribed` | `topic`: `string`, `ok`: `boolean` |
| `pong` | `ts`: `integer` |
| `screencast.started` | `topic`: `string`, `ordinal`: `integer` |
| `ok` | `result?`: `any` |
| `resync_required` | `topic?`: `string`, `reason`: one of `cursor_expired`, `epoch_changed`, `buffer_overflow` |

## Topics

Subscribe with `{ "type": "subscribe", "topic": "<topic>", "cursor"?: <last seq> }`. The reply `subscribed { from, to, complete }` says whether the replay was complete; `complete: false` (or `resync_required`) means reload from REST.

| Topic | Scope | Events |
|---|---|---|
| `sessions` | `sessions:read` | [`session.opened`](#event-session-opened), [`session.updated`](#event-session-updated), [`session.closed`](#event-session-closed), [`session.removed`](#event-session-removed) |
| `attention` | `attention:read` | [`attention.created`](#event-attention-created), [`attention.resolved`](#event-attention-resolved) |
| `vault.confirm` | `vault:read` | [`vault.confirm.created`](#event-vault-confirm-created), [`vault.confirm.resolved`](#event-vault-confirm-resolved) |
| `vault.config` | `vault:read` | [`vault.binding.changed`](#event-vault-binding-changed), [`vault.policy.changed`](#event-vault-policy-changed), [`vault.lock_state`](#event-vault-lock_state) |
| `vault.access` | `vault:read` | [`vault.access`](#event-vault-access) |
| `pages` | `sessions:read` | [`page.visited`](#event-page-visited) |
| `blocklist` | `blocklist:read` | [`blocklist.hit`](#event-blocklist-hit), [`blocklist.reloaded`](#event-blocklist-reloaded) |
| `system` | `system:read` | [`system.degraded`](#event-system-degraded), [`system.recovered`](#event-system-recovered), [`system.tick`](#event-system-tick), [`system.capacity`](#event-system-capacity), [`retention.completed`](#event-retention-completed) |
| `logs` | `logs:read` | [`log.record`](#event-log-record) |
| `notifications` | `notifications:read` | [`notification.created`](#event-notification-created), [`notification.updated`](#event-notification-updated) |
| `session:<id>` | `sessions:read` | [`session.opened`](#event-session-opened), [`session.updated`](#event-session-updated), [`session.closed`](#event-session-closed), [`session.removed`](#event-session-removed), [`session.warning`](#event-session-warning), [`tool.called`](#event-tool-called), [`page.visited`](#event-page-visited), [`screenshot.captured`](#event-screenshot-captured), [`vault.access`](#event-vault-access), [`blocklist.hit`](#event-blocklist-hit), [`attention.created`](#event-attention-created), [`attention.resolved`](#event-attention-resolved), [`vault.confirm.created`](#event-vault-confirm-created), [`vault.confirm.resolved`](#event-vault-confirm-resolved) |
| `screencast:<id>` | `sessions:read` | stream messages `meta`, `started`, `stopped`, `failed` and binary frames |

## Feed events

Payloads of `kind: "event"` frames, discriminated on `type`. DTO fields (`session`, `row`, `request`, …) are the same shapes the REST API returns.

<a id="event-session-opened"></a>
### `session.opened`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `session` | `object` | yes | keys `session_id`, `slug`, `owner`, `tenant_id`, `channel`, `engine`, `headless`, `incognito`, `persistence_mode`, `current_url`, `created_at`, `last_activity_at`, `closed_at`, `closed_reason`, `archived_at`, `lease_expires_at`, `lease_paused_at`, `lease_remaining_ms`, `state`, `live`, `disable_evaluate`, `vault_enabled`, `stealth`, `fingerprint`, `humanize`, `stealth_recorded`, `identity`, `proxy_label`, `counts`, `has_live_viewers`, `client` |

<a id="event-session-updated"></a>
### `session.updated`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `session` | `object` | yes | keys `session_id`, `slug`, `owner`, `tenant_id`, `channel`, `engine`, `headless`, `incognito`, `persistence_mode`, `current_url`, `created_at`, `last_activity_at`, `closed_at`, `closed_reason`, `archived_at`, `lease_expires_at`, `lease_paused_at`, `lease_remaining_ms`, `state`, `live`, `disable_evaluate`, `vault_enabled`, `stealth`, `fingerprint`, `humanize`, `stealth_recorded`, `identity`, `proxy_label`, `counts`, `has_live_viewers`, `client` |

<a id="event-session-closed"></a>
### `session.closed`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `session_id` | `string` | yes | pattern `^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$` |
| `closed_at` | `integer` | yes | ≥ 0 |
| `reason` | one of `user`, `operator`, `lease_expired`, `crash`, `shutdown`, `interrupted`, `launch_failed` | yes | — |

<a id="event-session-removed"></a>
### `session.removed`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `session_id` | `string` | yes | pattern `^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$` |
| `action` | one of `archived`, `unarchived`, `deleted` | yes | — |
| `at` | `integer` | yes | ≥ 0 |

<a id="event-session-warning"></a>
### `session.warning`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `session_id` | `string` | yes | pattern `^([a-z][a-z0-9-]{1,31})-([0-9a-z]{8})$` |
| `code` | `string` | yes | — |
| `message` | `string` | yes | — |
| `details` | `any` | no | — |

<a id="event-tool-called"></a>
### `tool.called`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `row` | `object` | yes | keys `event_id`, `session_id`, `tool`, `tab_id`, `ok`, `error_code`, `error_message`, `duration_ms`, `result_size_bytes`, `ts`, `trace_id`, `has_screenshot`, `args_json`, `result_text` |
| `has_detail` | `boolean` | yes | — |

<a id="event-page-visited"></a>
### `page.visited`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `row` | `object` | yes | keys `event_id`, `session_id`, `tab_id`, `url`, `title`, `domain`, `category`, `ts` |

<a id="event-screenshot-captured"></a>
### `screenshot.captured`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `row` | `object` | yes | keys `event_id`, `session_id`, `tool`, `kind`, `content_type`, `width`, `height`, `size_bytes`, `ts`, `url` |

<a id="event-vault-access"></a>
### `vault.access`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `row` | `object` | yes | keys `event_id`, `session_id`, `session_slug`, `tool_event_id`, `entry_name`, `handle`, `result`, `reason`, `evaluate_enabled`, `page_url`, `origin_check`, `principal_id`, `details`, `ts` |

<a id="event-blocklist-hit"></a>
### `blocklist.hit`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `row` | `object` | yes | keys `event_id`, `session_id`, `session_slug`, `tool_event_id`, `url`, `domain`, `pattern`, `source`, `tool`, `ts` |

<a id="event-attention-created"></a>
### `attention.created`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `request` | `object` | yes | keys `request_id`, `kind`, `session_id`, `session_slug`, `owner`, `reason`, `mode`, `options`, `status`, `message`, `resolved_by`, `resolution_reason`, `created_at`, `resolved_at`, `deadline_at`, `waited_ms`, `page_url`, `tool`, `event_id`, `entry_name` |

<a id="event-attention-resolved"></a>
### `attention.resolved`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `request` | `object` | yes | keys `request_id`, `kind`, `session_id`, `session_slug`, `owner`, `reason`, `mode`, `options`, `status`, `message`, `resolved_by`, `resolution_reason`, `created_at`, `resolved_at`, `deadline_at`, `waited_ms`, `page_url`, `tool`, `event_id`, `entry_name` |

<a id="event-vault-confirm-created"></a>
### `vault.confirm.created`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `request` | `object` | yes | keys `request_id`, `kind`, `session_id`, `session_slug`, `owner`, `reason`, `mode`, `options`, `status`, `message`, `resolved_by`, `resolution_reason`, `created_at`, `resolved_at`, `deadline_at`, `waited_ms`, `page_url`, `tool`, `event_id`, `entry_name` |

<a id="event-vault-confirm-resolved"></a>
### `vault.confirm.resolved`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `request` | `object` | yes | keys `request_id`, `kind`, `session_id`, `session_slug`, `owner`, `reason`, `mode`, `options`, `status`, `message`, `resolved_by`, `resolution_reason`, `created_at`, `resolved_at`, `deadline_at`, `waited_ms`, `page_url`, `tool`, `event_id`, `entry_name` |

<a id="event-vault-binding-changed"></a>
### `vault.binding.changed`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `handle` | `string` | yes | pattern `^[a-z0-9][a-z0-9._-]{0,127}$` |
| `action` | one of `created`, `updated`, `removed` | yes | — |

<a id="event-vault-policy-changed"></a>
### `vault.policy.changed`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `group_id` | `string` or `null` | yes | — |

<a id="event-vault-lock_state"></a>
### `vault.lock_state`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `unlocked` | `boolean` | yes | — |

<a id="event-blocklist-reloaded"></a>
### `blocklist.reloaded`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `patterns` | `integer` | yes | ≥ 0 |
| `skipped` | `integer` | yes | ≥ 0 |
| `loaded_at` | `integer` | yes | ≥ 0 |

<a id="event-system-degraded"></a>
### `system.degraded`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `event` | `object` | yes | keys `event_id`, `code`, `severity`, `message`, `details`, `first_seen_at`, `last_seen_at`, `count`, `resolved_at` |

<a id="event-system-recovered"></a>
### `system.recovered`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `event` | `object` | yes | keys `event_id`, `code`, `severity`, `message`, `details`, `first_seen_at`, `last_seen_at`, `count`, `resolved_at` |

<a id="event-system-tick"></a>
### `system.tick`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `now` | `integer` | yes | ≥ 0 |

<a id="event-system-capacity"></a>
### `system.capacity`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `live` | `integer` | yes | ≥ 0 |
| `max` | `integer` or `null` | yes | — |

<a id="event-retention-completed"></a>
### `retention.completed`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `at` | `integer` | yes | ≥ 0 |
| `pruned_rows` | `integer` | yes | ≥ 0 |
| `result` | one of `ok`, `partial`, `failed` | yes | — |
| `severity` | one of `info`, `warn`, `error` | no | — |

<a id="event-notification-created"></a>
### `notification.created`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at` |

<a id="event-notification-updated"></a>
### `notification.updated`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `notification` | `object` | yes | keys `notification_id`, `principal_id`, `type`, `title`, `body`, `session_id`, `session_slug`, `target`, `source_event_id`, `created_at`, `updated_at`, `count`, `read_at`, `dismissed_at` |

<a id="event-log-record"></a>
### `log.record`

| Field | Type | Required | Constraints |
|---|---|---|---|
| `record` | `object` | yes | keys `seq`, `ts`, `level`, `msg`, `module`, `trace_id`, `span_id`, `request_id`, `session_id`, `principal`, `transport`, `err`; additional keys allowed |

## Screencast

Start with `screencast.start { session_id, max_width?, max_height?, quality? }`; the reply `screencast.started` carries the `ordinal` that tags this screencast's binary frames. Frames are latest-wins and dropped under backpressure; each viewer may request its own size with `screencast.set_size`.

### Stream control messages

| type | Fields |
|---|---|
| `meta` | `session_id`: `string`, `ordinal`: `integer`, `device_width`: `integer`, `device_height`: `integer`, `page_scale`: `number`, `offset_top`: `number` |
| `started` | `session_id`: `string`, `ordinal`: `integer` |
| `stopped` | `session_id`: `string`, `reason`: one of `stopped`, `session_closed`, `session_crashed`, `connection_closed` |
| `failed` | `session_id`: `string`, `code`: `string`, `message?`: `string` |

### Binary frame header

Each binary frame is a 16-byte big-endian header followed by the JPEG bytes.

| Offset | Field | Encoding |
|---|---|---|
| 0 | `magic` | 4 ASCII bytes `BHSC` |
| 4 | `ordinal` | u32 |
| 8 | `seq` | u32 (monotonic per screencast; a lower seq after a higher one is dropped) |
| 12 | `width` | u16 (JPEG width) |
| 14 | `height` | u16 (JPEG height) |