Vancetope — WebSocket Protocol
Chat-Frame Wire-Format (
WebSocketEnvelope) between local clients (CLI, Desktop, Mobile) and the Vancetope Brain. This document describes only the inner frame — the outer multi-channel envelope, endpoint topology, and cross-pod routing are in live-ws. Frames, as described here, are thepayloadfield of theLiveEnvelopewithchannel="session". See also: live-ws (multi-channel wrapper, endpoints, cross-pod tunnel), architektur-scopes-clients (Sessions, Client Model), client-protokoll-erweiterbarkeit (external clients, Robot, MCP), java-cli-modulstruktur §2.1 (where message classes reside), identity-credentials (Accounts, OAuth2 → JWT). Status: v1 production (Live-WS refactor 06/2026 wraps the format described here into theLiveEnvelope.sessionchannel — the inner frame remains unchanged).
1. Fundamentals
Connection
- Transport: WebSocket (text frames, JSON payload)
- Default URL:
ws://host:port/brain/{tenant}/wsorwss://...in Production. The Tenant is in the path so the AccessFilter can cross-check path-tenant against JWT-tenant. - Encoding: UTF-8
- Every frame on this endpoint is a
LiveEnvelope{channel, sessionId, payload}; everything described below in this document is the form of thepayloadfield whenchannel="session". - A WebSocket connection can be bound to a maximum of one Session; the binding happens explicitly via
session-create/session-resumeafter the handshake (see architektur-scopes-clients §2). A Session has a maximum of 1 active connection per User — withSessionDocument.allowMultipleClients=true, connections from different Users coexist in the same Roster (see multi-user-sessions). The Mongo bind remains single-binding; secondary participants only exist in the in-memorySessionConnectionRegistry.
Naming Convention
In contrast to the nimbus protocol, fully spelled-out keys are used. Performance is not critical here (personal Think Tool, tens of connections), while readability in logs, generated TypeScript, and protocol debugging is gained.
- lowerCamelCase for all field names
- English terms
- No abbreviations in JSON keys (
typeinstead oft,datainstead ofd) - Timestamps are Unix milliseconds (
long), fields end withTimestamporAt - IDs are strings with typical prefixes:
sess_...,usr_...,eng_...,proj_...
Envelope
Every message follows a unified envelope:
{
"id": "string (optional)",
"replyTo": "string (optional)",
"type": "string (required)",
"data": { ... }
}
| Field | Sender | Purpose |
|---|---|---|
type |
Sender | Message type (see type catalog) |
data |
Sender | Payload, type-specific |
id |
Sender of a request | Request ID for correlation with response. Unique per connection |
replyTo |
Sender of a response | Carries the id value of the original request |
Rules:
- A message without an expected response can omit
id(fire-and-forget, e.g., status broadcast from server) - A response must set
replyTo idandreplyToare not set simultaneously on the same message (either request or response — not both)- The combination of Connection +
idmust be unique within a Session
2. Authentication
Authentication happens during the WebSocket handshake via a JWT (JSON Web Token). There is no login message on the WebSocket — without a valid JWT, the server rejects the HTTP upgrade request with HTTP 401, so a WebSocket connection is not even established.
The client obtains the JWT via a separate OAuth2/Password flow against the Brain (REST, outside this protocol — see identity-credentials §2). The WebSocket only knows: “Show JWT or the door stays closed”.
Handshake Request
The client sends the following headers during the HTTP upgrade:
GET /brain/acme/ws?profile=foot&name=mikes-laptop HTTP/1.1
Host: brain.local:9990
Upgrade: websocket
Connection: Upgrade
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Vancetope-Profile: foot
X-Vancetope-Client-Version: 0.1.0
X-Vancetope-Client-Name: mikes-laptop
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
| Header | Required | Description |
|---|---|---|
Authorization: Bearer <jwt> |
yes | Signed JWT, at least sub and exp claims |
X-Vancetope-Profile |
no (default web) |
Open string, shape ^[a-z][a-z0-9_-]{0,31}$ |
X-Vancetope-Client-Version |
yes | SemVer of the client, for compatibility checks |
X-Vancetope-Client-Name |
no | Optional client identifier (logs/UI) |
Headers and query parameters are equivalent — browser clients cannot set custom headers on the WS upgrade and use ?profile=, ?clientVersion=, ?name= as a fallback.
Profile is an open string, not an Enum. The server only checks the format. Identity is not validated — what happens with an unknown profile name is solely decided by the Recipe Resolver. The canonical values are maintained in de.mhus.vance.api.ws.Profiles as string constants; full form and extensibility see client-protokoll-erweiterbarkeit §2.1a.
Canonical values:
| Value | Meaning | Local Tools |
|---|---|---|
foot |
Java CLI (vance-foot, Picocli + JLine) |
Full (Shell, Filesystem) |
web |
Browser UI (@vance/vance-face, Chat Editor) — Wire-Default |
None — Web clients do not register client-tool-* |
mobile |
PWA / Native App (later) | Restricted |
daemon |
Reserved name for vance-foot -d headless mode (planned). Currently no special effect |
— |
The Web UI is a restricted WebSocket client: The Chat Editor connects via WS (live stream + steering), all other editors remain REST-only (see web-ui.md §3-§4). Web clients must not use client-tool-register/client-tool-invoke/client-tool-result — Workspace Tools are exclusively foot concern.
Which set of Tools/Manuals/Prompts is attached to a conversation is decided by the Recipe Profile Block (recipes §6a) — Profile is the discriminator for this block.
JWT Claims
The server validates the signature and exp (expiration time). The following are read:
| Claim | Type | Required | Description |
|---|---|---|---|
sub |
string | yes | User/Account ID (usr_... or acc_...) |
exp |
number | yes | Expiration time (Unix seconds) |
iat |
number | no | Issued at time |
iss |
string | no | Issuer; checked if configured server-side |
name |
string | no | Display name of the user |
tid |
string | no | Tenant ID (tnt_...). Ignored in v1; mandatory in v2+ |
The JWT carries only identity. Profile, client version, and client name reside in the handshake headers (or URL query) because they vary per connection and are not part of the identity. Session binding is no longer a handshake concern — it happens explicitly via session-create / session-resume on the open WebSocket connection.
Handshake Errors
The server aborts the upgrade with an HTTP status transported in the WebSocket handshake response:
| Status | Cause |
|---|---|
| 401 Unauthorized | Authorization header missing, token invalidly signed, token expired, issuer mismatch |
| 400 Bad Request | X-Vancetope-Client-Version missing; or X-Vancetope-Profile violates the shape pattern |
| 403 Forbidden | JWT valid, but account blocked or tenant not allowed |
The client recognizes the error situation by the HTTP status — a WebSocket connection is not established in these cases.
Server → Client: welcome
First message the server sends after a successful handshake. The client must not send messages before this (except ping frames are not answered by the server until welcome — but this is an edge case).
{
"type": "welcome",
"data": {
"userId": "usr_42",
"displayName": "Mike",
"tenantId": "tnt_default",
"server": {
"version": "0.1.0",
"protocolVersion": 1,
"pingInterval": 30,
"capabilities": ["engines", "memory", "workflows"]
}
}
}
The connection starts without a bound Session — before the client sends a session-required command, it must bind a Session via session-create or session-resume.
| Field | Type | Description |
|---|---|---|
userId |
string | Internal User ID (from JWT sub) |
displayName |
string | Display name (from JWT name or account profile) |
tenantId |
string | Always tnt_default in v1, reserved for v2+ |
server.version |
string | SemVer of the Brain |
server.protocolVersion |
int | Integer version of the wire protocol. Currently 1. Incompatible change → new version |
server.pingInterval |
int | Seconds between expected ping messages (see §3) |
server.capabilities |
string[] | Feature flags supported by the server. The client can gate optional features on this |
3. Ping / Pong
Keeps the connection alive and measures latency.
Client → Server: ping
{
"id": "ping_17",
"type": "ping",
"data": {
"clientTimestamp": 1745440800000
}
}
Server → Client: pong
{
"replyTo": "ping_17",
"type": "pong",
"data": {
"clientTimestamp": 1745440800000,
"serverTimestamp": 1745440800120
}
}
Rules:
- Client sends
pingeveryserver.pingIntervalseconds (fromwelcome) - If no
pingis received for longer thanpingInterval + 10s, the server closes the connection - The client can roughly calculate the one-way latency from
serverTimestamp - clientTimestamp pongis purely an ACK — the server can also derive session activity from other messages, but must answer everyping
4. Logout
Client → Server: logout
{
"type": "logout",
"data": {}
}
The client explicitly ends the Session. The Session is closed server-side, not just the Connection. A later session-resume with the same sessionId will fail with 404.
Distinction: Simply closing the WebSocket connection (without logout) does not end the Session — the client can later re-bind to the same sessionId on a new connection via session-resume (and a valid JWT). This is the usual case for CLI exit (connection is closed, session continues until server timeout).
After logout, the server closes the connection; a response is not required.
5. Error Model
errorCode follows HTTP semantics, even though the transport is WebSocket:
| Range | Meaning | Examples |
|---|---|---|
| 400 | Bad Request — message formally invalid | Required field missing, unknown type, data not parsable |
| 401 | Unauthorized — active JWT became invalid during the session | Server revokes the session with error and closes |
| 403 | Forbidden — authenticated, but no permission | Operation belongs to another user, tenant blocked |
| 404 | Not Found | Referenced resource (Think Process, Project, Task) does not exist |
| 409 | Conflict | Conflict with current state, e.g., Think Process already started |
| 500 | Internal Server Error | Unexpected server error |
errorMessage is text intended for logs — not for end-user display (the client should derive and localize messages from the errorCode itself).
General error message (if a request fails and there is no type-specific *Response):
{
"replyTo": "42",
"type": "error",
"data": {
"errorCode": 400,
"errorMessage": "Unknown message type: frobnicate"
}
}
Note: Auth errors during connection setup do not come as an error message, but as an HTTP status before the upgrade (see §2, “Handshake Errors”).
5.1 Session Bind Errors (session-create / session-resume / session-bootstrap)
A Session is associated with a maximum of 1 active connection (§1). The bind operations follow a unified error table that the client evaluates for UI display (e.g., “Session occupied” indicator in the picker):
errorCode |
Meaning | When |
|---|---|---|
| 400 | Bad Request | sessionId missing, payload not parsable, invalid fields |
| 403 | Foreign Session | JWT valid, but the referenced session belongs to another user or tenant |
| 404 | Not Found / Closed | Session does not exist or is not in OPEN status (e.g., after logout) |
| 409 | Already Bound | Session exists and belongs to the user, but is currently bound to another WS connection (“occupied”) |
| 409 | Profile Mismatch | Session was created with a different connection profile than the current connection carries — see below |
Profile Mismatch (409): Sessions are bound to the Profile with which they were created — a Foot Session cannot be resumed by a Web client, and vice versa. Reason: Tools/Manuals/Prompt defaults were resolved by the Profile Block when the session processes were spawned; a cross-profile resume would offer the new client tools it cannot host (Foot tools on Web client → failing tool calls). The auto-resume logic in session-bootstrap (no explicit sessionId) automatically filters out mismatched sessions; the explicit resume path returns 409. The error message names both profile values ('foot' vs. 'web') so clients can build a clear UI message.
The client can, before a bind attempt, use session-list (see SessionSummary.bound and SessionSummary.profile) to determine if a session is occupied or profile-incompatible, and let the user choose without a round-trip error. The bind attempt itself remains the only authoritative source — race conditions between session-list and session-resume are possible and will be clearly visible via the 409 path.
session-unbind (Client → Server) explicitly releases the binding without closing the session — intended for web tabs that leave their chat without ending the session. Simply closing the WS connection leads to a delayed server-side cleanup; an immediate resume in another tab after tab close might encounter 409 until the cleanup takes effect.
6. Type Catalog (Current)
Source of truth: vance-api/src/main/java/de/mhus/vance.api.ws/MessageType.java. This table is maintained when new frames are added; in case of drift, the code takes precedence.
Connection Lifecycle
| Type | Direction | Purpose |
|---|---|---|
welcome |
Server → Client | First message after successful handshake — carries user and server info (no session) |
ping |
Client → Server | Keep-alive |
pong |
Server → Client | Keep-alive ACK |
logout |
Client → Server | Close session |
error |
Server → Client | Generic error response |
Session Bind and Discovery
| Type | Direction | Purpose |
|---|---|---|
session-create |
Client → Server | Create a new Session in a Project and bind it to the connection |
session-resume |
Client → Server | Bind own or shared Session to the connection (see §5.1). Shared sessions (allowMultipleClients=true) allow the non-owner to join as a secondary participant — Mongo bind remains with the owner. |
session-unbind |
Client → Server | Release the binding of the Session to the current connection without closing the Session |
session-bootstrap |
Client → Server | Compound command: session-create OR session-resume + optional Process spawns + optional initial message in a single frame |
session-list |
Client → Server | List sessions in the Tenant (own + all allowMultipleClients=true; SessionSummary with bound and userId fields, see §5.1) |
session-metadata-patch |
Client → Server | Owner-only metadata patch (title, icon, tags, pin, allowMultipleClients for multi-user toggle). Reply is the refreshed SessionMetadataDto. |
session-roster |
Server → Client | Push: current multi-user roster of the bound session. Payload: SessionRosterData { sessionId, participants: [{ editorId, userId, displayName }] }. Pushed to all connections of the session after each roster mutation (Join/Leave/Bind escalation). Spec: multi-user-sessions §6. |
session-who |
Client → Server | On-demand roster lookup with reply SessionRosterData. Drives /who slash command and initial baseline on client attach (race-free against async push). |
project-list |
Client → Server | List projects in the Tenant (optionally filtered by a project group) |
projectgroup-list |
Client → Server | List project groups in the Tenant |
Think Process Control
| Type | Direction | Purpose |
|---|---|---|
process-create |
Client → Server | Create a new Think Process in the bound Session (by Recipe or Engine name) |
process-steer |
Client → Server | User input / steering to a running Think Process — this is the chat send path |
process-list |
Client → Server | List Think Processes of the bound Session |
process-compact |
Client → Server | Manually trigger memory compaction for a Think Process |
process-skill |
Client → Server | Activate / deactivate / list Skills on a Think Process |
Chat Stream (Server → Client)
| Type | Direction | Purpose |
|---|---|---|
chat-message-stream-chunk |
Server → Client | Optimistic streaming chunk of an Assistant response. Replaced by the corresponding chat-message-appended. Payload: ChatMessageChunkData |
chat-message-appended |
Server → Client | Authoritative persist confirmation — a ChatMessageDocument has been written. Payload: ChatMessageAppendedData with multi-user fields: senderUserId (nullable, USER-Turn-Author), senderDisplayName (nullable, captured), addressedToAgent (Default true; false marks background turns that did not wake the Agent — see multi-user-sessions §2) as well as thinking (nullable): the raw narration of the model (verbatim, including <think>…</think> / Harmony-analysis-markup), which is streamed live but not part of the final content — verbatim encapsulated and persisted per turn so the user can read it. The Web UI shows it as an expandable “Thoughts” area (see llm-resource-management §4.1). Frame is fanned out to all connections of the session in multi-user sessions; in solo sessions, the user echo variant is suppressed as before. |
User Progress Side-Channel
| Type | Direction | Purpose |
|---|---|---|
process-progress |
Server → Client | Live status updates from running Processes (variants metrics / plan / status). Completely separate from the chat stream, not in conversation history. Spec: user-progress-channel.md |
Document Invalidation Side-Channel
| Type | Direction | Purpose |
|---|---|---|
document-invalidate |
Server → Client | A server-side tool (doc_write / doc_edit / doc_append / doc_replace_lines / doc_note_*) has mutated a document of the bound session. Payload: DocumentInvalidateNotification { documentId, path, kind: "body"\|"notes" }. Cortex tabs react with a refresh (3-way merge for dirty buffer). Cross-pod path without Redis dependency, firing in parallel to the documents-channel-changed-push. Plan: planning/cortex-document-invalidation.md |
Inbox Subsystem
| Type | Direction | Purpose |
|---|---|---|
inbox-list / inbox-item / inbox-answer / inbox-delegate / inbox-archive / inbox-dismiss |
Client → Server | CRUD and lifecycle operations on user interaction items |
inbox-item-added / inbox-item-updated |
Server → Client | Push notification for Inbox mutations |
inbox-pending-summary |
Server → Client | Welcome-time overview of pending items |
Spec: user-interaction.md.
Client Tool Roundtrip (CLI / Desktop only)
| Type | Direction | Purpose |
|---|---|---|
client-tool-register |
Client → Server | Register the client’s Tool Registry with the Brain |
client-tool-invoke |
Server → Client | Brain calls a client-registered Tool |
client-tool-result |
Client → Server | Result of a client-tool-invoke |
Web clients do not send any of these three frames (see §2). The Web UI is a view consumer, not a tool provider.
7. Outlook (Not Yet Defined)
Will be specified in later revisions of this document as soon as the respective features are implemented in vance-brain:
- Task Operations (Marvin Tree manipulation):
task-rerun,task-cancel,task-split,task-move,task-updated(Broadcast) - Memory:
memory-add,memory-search,memory-browse - Session Switch:
session-switch-project,session-switch-engine - Approval Flow:
approval-request,approval-decision(separate from Inbox path)
The corresponding message classes will be implemented in the vance-api module (package de.mhus.vance.api.ws) as POJOs with Jackson annotations, typed IDs (SessionId, ThinkProcessId etc.) and JSpecify @Nullable marking where fields may be omitted. See java-cli-modulstruktur §2.1.
Removed from outlook (are implemented today): Streaming (chat-message-stream-chunk + chat-message-appended), User Steering (process-steer), Tool Roundtrips (client-tool-*), Live Progress (process-progress).
8. Versioning
server.protocolVersion is an integer that is incremented by 1 for incompatible changes. Backward-compatible extensions (new type values, new optional fields) do not increment the version.
The client checks in the welcome message whether protocolVersion matches its expected versions and otherwise disconnects with a clear error message (“Server protocol version X, client expects Y”).
| *See also: architektur-scopes-clients | client-protokoll-erweiterbarkeit | java-cli-modulstruktur | identity-credentials* |