13 KiB
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
pingframes every 54 seconds - Client must respond with
pongwithin 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:
{
"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:
{ "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):
{
"id": "uuid",
"channel_id": "uuid",
"role": "user",
"content": "message text",
"user_id": "uuid"
}
Assistant message (completion chain):
{
"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):
{
"channel_id": "uuid",
"participant_id": "uuid",
"participant_type": "persona",
"display_name": "Persona Name"
}
typing.user
Human typing indicator (relayed to other channel participants):
{
"channel_id": "uuid",
"user_id": "uuid",
"display_name": "Jane Doe"
}
user.presence
Emitted on WebSocket connect/disconnect:
{
"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:
{
"channel_id": "uuid",
"from_user": "uuid",
"content": "truncated to 120 chars..."
}
notification.new
Full notification object (same shape as REST GET /notifications):
{
"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:
{ "id": "uuid" }
Mark-all-read:
{ "action": "mark_all_read" }
workflow.assigned
Targeted via SendToUser to each team member:
{
"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:
{
"assignment_id": "uuid",
"claimed_by": "uuid",
"channel_id": "uuid"
}
workflow.advanced
Targeted via SendToUser to all user participants in the channel:
{
"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:
{
"channel_id": "uuid",
"workflow_id": "uuid",
"stage": "final_stage_key"
}
role.fallback
Published to bus with Room: "admin":
{
"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):
{
"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 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.