# 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 ``` ws://{host}{BASE_PATH}/ws?token={access_token} ``` **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.