---
audience: developer
summary: Listen for messages, presence, typing, AI status, and plan updates over a
  single WS connection.
title: Subscribe to WebSocket events
path: tutorials/developer/subscribe-to-websocket-events
status: published
---

# Subscribe to WebSocket events

ScaiWave streams real-time events over a single WebSocket per
client. The protocol is JSON-over-WS; subscribe to a tenant scope
and the server fans out everything you have permission to see.

## The endpoint

```text
wss://<host>/v1/stream?token=<jwt>
```

The token is passed via query string because browser WebSockets
don't support custom headers. The server validates it the same way
HTTP middleware does and rejects bad tokens with a close frame.

> **Don't log the URL.** It contains a Bearer token. Most server
> log configs scrub it, but be careful in browser devtools and
> proxy logs.

## Hello

After the upgrade succeeds, the server sends:

```json
{
  "type": "hello",
  "session_id": "<uuid>",
  "server_name": "scaiwave.scailabs.com",
  "tenant_id": "<your-tenant-id>",
  "ts": "2026-05-17T11:00:00Z"
}
```

If you don't see this within a couple of seconds, the token wasn't
accepted; check the close frame's `code` and `reason`.

## What you receive

The same event types whether you sent the action or someone else
did. Each event carries a `type` discriminator.

### Room events

- `swp.room.message` — a message was sent.
- `swp.room.reaction` — a reaction was added.
- `swp.room.redaction` — an event was redacted.
- `swp.room.message.chunk` — streaming chunk (during AI
  generation). Includes `content.chunk_text` and `content.done`.
- `swp.room.member` — membership changed (join/leave/ban).
- `swp.room.ai_engagement` — AI engagement state changed.
- `swp.room.name` — room renamed.
- `swp.room.topic` — topic changed.
- `swp.room.read_marker` — your own read marker advanced
  (cross-device sync).

### AI events

- `swp.ai.status` — AI's status update ("thinking", "calling
  web_search", "generating", null when done).
- `swp.ai.context_usage` — current token usage on the AI's prompt.
- `swp.plan.updated` — full plan snapshot (state + steps).

### Sidekick events

- `swp.sidekick.spawned` — sidekick created in this room.
- `swp.sidekick.result` — sidekick completed; result attached.
- `swp.sidekick.cancel` — sidekick cancelled.

### Presence / typing

- `typing` — `{room_id, participant_id, typing: bool}`.
- `presence` — `{participant_id, status: "online"|"idle"|...}`.

### Calls

- `swp.call.invite` — incoming call (with timeout).
- `swp.call.state` — call participant state changed.

### Federation

- `swp.federation.event` — opaque to the client; surfaces newly-
  federated room state changes.

### Bridges

- `swp.bridge.message` — message arrived from a bridge.
- `swp.bridge.status` — bridge state change.

## Filtering

The server fans out everything you can see (every room you're a
member of). To narrow what you process, filter client-side on
`data.room_id` or `data.type`.

## Reconnect

The server idles connections after 60s without activity from
either side (ping/pong is automatic). On a clean close (code 1000)
or abrupt disconnect, reconnect with exponential backoff.

ScaiWave doesn't replay missed events on reconnect — use the sync
endpoint:

```bash
GET /v1/sync?since=<last-stream-position>&limit=100&timeout=30000
```

Long-polls; returns everything you missed since `<last-stream-position>`,
then either resolves (data available) or after `timeout` (idle —
re-poll). The web client uses this on reconnect, then resumes WS.

## Minimal client (Python)

```python
import asyncio
import json
import websockets

async def main():
    uri = "wss://localhost:8000/v1/stream?token=mock-dev-token"
    async with websockets.connect(uri) as ws:
        async for raw in ws:
            event = json.loads(raw)
            if event["type"] == "swp.room.message":
                print(f"new message in {event['room_id']}: "
                      f"{event['content'].get('body', '')[:80]}")
            elif event["type"] == "swp.ai.status":
                print(f"AI {event['participant_id']}: {event.get('status')}")

asyncio.run(main())
```

## Minimal client (TypeScript)

```ts
const ws = new WebSocket(`wss://${HOST}/v1/stream?token=${encodeURIComponent(token)}`);

ws.addEventListener("message", (msg) => {
  const event = JSON.parse(msg.data);
  switch (event.type) {
    case "swp.room.message":
      console.log("new msg:", event.content.body);
      break;
    case "swp.ai.status":
      console.log(`AI status: ${event.status}`);
      break;
  }
});

ws.addEventListener("close", (e) => {
  console.warn(`WS closed: ${e.code} ${e.reason}`);
  // Reconnect with backoff…
});
```

## Where to go next

- Reference: [WebSocket events](/docs/scaiwave/reference/websocket-events) — full catalog.
- Tutorial: [Write a plugin](/docs/scaiwave/tutorials/developer/write-a-plugin) — emit your own event types.
