# WebSocket Protocol

Real-time event delivery. Single connection per client. All event
delivery uses targeted `SendToUser` — events are pushed to every
WebSocket connection belonging to the recipient user.

### Connection

**Preferred (v0.28.8+): Ticket exchange**

```
POST /api/v1/ws/ticket     (authenticated via JWT Bearer header)
→ 200  { "ticket": "<opaque-hex-string>" }
```

Then connect with the opaque ticket:

```
ws://{host}{BASE_PATH}/ws?ticket={ticket}
```

Tickets are single-use (deleted on validation), 30-second TTL, and
stored in server memory. This avoids exposing the JWT in server logs,
proxy logs, and browser history.

**Legacy (deprecated): JWT query parameter**

```
ws://{host}{BASE_PATH}/ws?token={access_token}
```

Still accepted for backward compatibility with pre-v0.28.8 clients.
Server logs a deprecation warning on each legacy connection. Will be
removed in a future version.

**Non-browser clients** may also use the `Authorization: Bearer <jwt>`
header on the upgrade request.

**Origin validation:** The WebSocket upgrader validates the `Origin`
header against `CORS_ALLOWED_ORIGINS`. Connections from unlisted
origins are rejected at upgrade time. In development (no
`CORS_ALLOWED_ORIGINS` set), all origins are accepted.

**Lifecycle:**
- Server sends WebSocket-level `ping` frames every 54 seconds
- Client must respond with `pong` within 60 seconds or disconnection
- Max inbound message size: 4 KB
- Write deadline: 10 seconds per frame
- Connection ID: `{user_id}-{timestamp}` (internal, not exposed to client)

### Event Envelope

All messages are JSON. The field name is `event`, not `type`:

```json
{
  "event": "message.created",
  "payload": { ... },
  "ts": 1710000000000
}
```

| Field | Type | Description |
|-------|------|-------------|
| `event` | string | Dot-delimited event label |
| `room` | string | Optional — room scope (currently unused client-side, see § Room Model) |
| `payload` | object | Event-specific data |
| `ts` | int64 | Unix milliseconds |

### Client → Server Events

The client can send JSON events to the server. Only events with
`DirFromClient` or `DirBoth` routing are accepted; all others are
silently dropped.

**Application-level ping:**

```json
{ "event": "ping" }
```

Server responds with `{ "event": "pong", "ts": ... }`. This is
separate from the WebSocket-level ping/pong frames.

**Typing (human → other participants):**

Client sends a `chat.typing.{channelID}` event. The server tags it
with `SenderID` and `ConnID`, then rebroadcasts to other participants
via `SendToUser` per channel membership.

### Room Model (Planned)

`JoinRoom` and `LeaveRoom` methods exist on the connection struct but
are **not yet wired** — no client-side subscribe/unsubscribe mechanism
is implemented. The `subscribeToBus` handler has room filtering logic
(`if e.Room != "" && !c.rooms[e.Room]`) but since connections never
join rooms, all room-scoped `Bus.Publish` calls are effectively
no-ops for WebSocket delivery.

Current workaround: all user-facing events use `Hub.SendToUser()`
which bypasses room filtering. Room-scoped `Bus.Publish()` calls
(e.g. `workflow.claimed` with `Room: assignmentID`) exist as
forward-looking plumbing but do not reach WebSocket clients today.

### Event Routing Table

Direction is determined by `events.routeTable` in `server/events/types.go`.
Prefix matching: longest matching prefix wins; unmatched labels default
to `DirLocal` (server-only, never crosses the wire).

| Label | Direction | Delivery | Description |
|-------|-----------|----------|-------------|
| `message.created` | → client | `SendToUser` per channel | New message in channel |
| `message.updated` | → client | `SendToUser` | Message content changed |
| `message.deleted` | → client | `SendToUser` | Message removed |
| `channel.created` | → client | `SendToUser` | New channel created |
| `channel.updated` | → client | `SendToUser` | Channel metadata changed |
| `channel.deleted` | → client | `SendToUser` | Channel removed |
| `channel.member.*` | → client | `SendToUser` | Participant added/removed/role changed |
| `typing.start` | → client | `SendToUser` | AI persona started generating |
| `typing.stop` | → client | `SendToUser` | AI persona finished generating |
| `typing.user` | → client | `SendToUser` per channel | Human typing indicator |
| `chat.typing.*` | ↔ both | Bus (prefix match) | Legacy typing relay |
| `user.presence` | → client | `SendToUser` | Online/offline status change |
| `user.mentioned` | → client | `SendToUser` | User @mentioned in a channel |
| `notification.new` | → client | `SendToUser` | New persisted notification |
| `notification.read` | → client | `SendToUser` | Badge sync across tabs |
| `role.fallback` | → client | Bus (Room: `admin`) | Role fallback activated (admin-targeted) |
| `workflow.assigned` | → client | `SendToUser` per team | New assignment for team members |
| `workflow.claimed` | → client | `SendToUser` per channel | Assignment claimed |
| `workflow.advanced` | → client | `SendToUser` per channel | Stage advanced |
| `workflow.completed` | → client | `SendToUser` per channel | Workflow finished |
| `workspace.file.*` | → client | Bus | Workspace file changed |
| `tool.call.*` | → client | `SendToUser` | Browser tool invocation |
| `tool.result.*` | ← client | Bus | Browser tool result |
| `system.notify` | → client | Bus | System broadcast |
| `ping` | ← client | Direct | Application-level keepalive |
| `pong` | → client | Direct | Response to application ping |

---

## Event Payload Shapes

### `message.created`

User message (mention_only / DM relay):

```json
{
  "id": "uuid",
  "channel_id": "uuid",
  "role": "user",
  "content": "message text",
  "user_id": "uuid"
}
```

Assistant message (completion chain):

```json
{
  "id": "uuid",
  "channel_id": "uuid",
  "role": "assistant",
  "content": "response text",
  "model": "model-id",
  "participant_type": "persona",
  "participant_id": "uuid",
  "display_name": "Persona Name",
  "avatar": "/path/to/avatar",
  "tokens_used": 1234,
  "chain_depth": 1
}
```

### `typing.start` / `typing.stop`

AI typing indicator (targeted via `SendToUser`):

```json
{
  "channel_id": "uuid",
  "participant_id": "uuid",
  "participant_type": "persona",
  "display_name": "Persona Name"
}
```

### `typing.user`

Human typing indicator (relayed to other channel participants):

```json
{
  "channel_id": "uuid",
  "user_id": "uuid",
  "display_name": "Jane Doe"
}
```

### `user.presence`

Emitted on WebSocket connect/disconnect:

```json
{
  "user_id": "uuid",
  "status": "online|offline"
}
```

Note: the DB CHECK constraint allows `online`, `away`, `offline` but
the WebSocket hub currently only emits `online` (on connect) and
`offline` (on last connection closed). `away` is reserved for future
idle detection.

### `user.mentioned`

Targeted to the mentioned user:

```json
{
  "channel_id": "uuid",
  "from_user": "uuid",
  "content": "truncated to 120 chars..."
}
```

### `notification.new`

Full notification object (same shape as REST `GET /notifications`):

```json
{
  "id": "uuid",
  "user_id": "uuid",
  "type": "kb.ready",
  "title": "Knowledge base ready",
  "body": "optional detail",
  "resource_type": "knowledge_base",
  "resource_id": "uuid",
  "is_read": false,
  "created_at": "..."
}
```

### `notification.read`

Single notification marked read:

```json
{ "id": "uuid" }
```

Mark-all-read:

```json
{ "action": "mark_all_read" }
```

### `workflow.assigned`

Targeted via `SendToUser` to each team member:

```json
{
  "channel_id": "uuid",
  "team_id": "uuid",
  "stage_name": "Review",
  "assigned_to": "uuid|empty"
}
```

### `workflow.claimed`

Targeted via `SendToUser` to all user participants in the channel:

```json
{
  "assignment_id": "uuid",
  "claimed_by": "uuid",
  "channel_id": "uuid"
}
```

### `workflow.advanced`

Targeted via `SendToUser` to all user participants in the channel:

```json
{
  "channel_id": "uuid",
  "workflow_id": "uuid",
  "stage": "stage_key",
  "stage_name": "Stage Name"
}
```

### `workflow.completed`

Targeted via `SendToUser` to all user participants in the channel:

```json
{
  "channel_id": "uuid",
  "workflow_id": "uuid",
  "stage": "final_stage_key"
}
```

### `role.fallback`

Published to bus with `Room: "admin"`:

```json
{
  "role": "primary",
  "operation": "chat|embedding",
  "primary_model": "model-a",
  "fallback_model": "model-b",
  "message": "Role \"primary\" primary (model-a) failed — using fallback (model-b)"
}
```

### `tool.call.*`

Browser tool invocation (targeted via `SendToUser`):

```json
{
  "tool_name": "...",
  "call_id": "...",
  "input": { ... }
}
```

Client responds with `tool.result.*` containing the execution result.

---

## Appendix: Enums

### Channel Types

`direct` (single-user), `dm` (human-to-human, AI mention-only),
`group` (multi-user/multi-model), `channel` (named persistent),
`workflow` (staged), `service` (autonomous task execution)

### Channel AI Modes

`auto` (default), `mention_only` (default for `dm`), `off`

### Participant Types

`user`, `persona`, `session`

### Participant Roles

`owner`, `member`, `observer`, `visitor`

### Presence Statuses

`online`, `away`, `offline`

DB CHECK constraint: `online`, `away`, `offline`. Runtime: only
`online` and `offline` are emitted by the WebSocket hub. `away` is
reserved for future idle-timeout detection.

### Message Roles

`user`, `assistant`, `system`, `tool`

### Scopes

`personal`, `team`, `global`

### Visibility (model catalog)

`enabled`, `disabled`, `team`

### User Roles

`admin`, `user`

### Team Member Roles

`admin`, `member`

### Provider Types

`openai`, `anthropic`, `openrouter`, `venice` (extensible via registry)

### Model Types

`chat`, `embedding`, `image`

### Memory Scopes

`user`, `persona`, `persona_user`

### Memory Statuses

`active`, `pending_review`, `archived`

### Workspace Owner Types

`user`, `project`

### Workspace Statuses

`active`, `archived`, `deleting`

### Index Statuses

`pending`, `indexing`, `ready`, `error`, `skipped`

### KB Document Statuses

`pending`, `chunking`, `embedding`, `ready`, `error`

### Provider Health Statuses

`healthy`, `degraded`, `down`

### Routing Policy Types

`provider_prefer`, `team_route`, `cost_limit`, `model_alias`

### Extension Tiers

`browser` (implemented), `starlark` (future), `sidecar` (future)

### Grant Types

`team_only`, `global`, `groups`

### Resource Grant Scopes

`persona`, `kb`

### Notification Types

`role.fallback`, `kb.ready`, `kb.error`, `grant.changed`,
`memory.extracted`, `user.mentioned`, `workflow.assigned`,
`workflow.claimed`, `task.completed`, `task.failed`,
`task.budget_exceeded`

See [enums.md](enums.md#notification-types) for full descriptions.

### Workflow Assignment Statuses

`unassigned`, `claimed`, `completed`

### Task Run Statuses

`queued`, `running`, `completed`, `failed`, `budget_exceeded`, `cancelled`

### Workflow Instance Statuses

`active`, `completed`, `stale`, `cancelled`

### Git Auth Types

`https_pat`, `https_basic`, `ssh_key`

### Policies

| Key | Default | Description |
|-----|---------|-------------|
| `allow_registration` | `"true"` | Allow new user self-registration |
| `allow_user_byok` | `"true"` | Allow users to add personal API keys |
| `allow_user_personas` | `"true"` | Allow users to create personal Personas |
| `allow_team_providers` | `"true"` | Allow team admins to configure team providers |
| `kb_direct_access` | `"true"` | Show KB picker in channel (false = strict enterprise mode) |
| `require_email` | `"false"` | Require email on registration |
| `default_system_prompt` | `""` | Injected into all conversations (admin override) |

---

## Page Routes (Non-API)

Server-rendered Go template surfaces. Not REST endpoints — these
return HTML pages.

| Route | Surface | Description |
|-------|---------|-------------|
| `/login` | Login | Standalone login/register page |
| `/` | Chat | Main chat interface |
| `/chat/:chatID` | Chat | Chat with specific channel loaded |
| `/editor` | Editor | Workspace file editor |
| `/editor/:wsId` | Editor | Editor with specific workspace |
| `/notes` | Notes | Notes interface |
| `/notes/:noteId` | Notes | Notes with specific note loaded |
| `/admin` | Admin | Platform administration |
| `/admin/:section` | Admin | Admin with specific section |
| `/settings` | Settings | User settings |
| `/settings/:section` | Settings | Settings with specific section |

All page routes (except `/login`) require authentication via
`AuthOrRedirect` middleware (reads `sb_token` cookie). Admin routes
additionally require `RequireAdminPage()`.

**Dynamic surface routing** for admin-installed extension surfaces is
not yet implemented. The current five surfaces are hardcoded in Go
route registration. Future: extension manifests declare surface routes,
the page engine dynamically registers them with appropriate data
loaders. See EXTENSIONS.md for the design direction.
