WebSocket Protocol Overview
May 16, 2026 · View on GitHub
Introduction
OpenContracts uses WebSocket connections to deliver real-time streaming output from AI agents and real-time push of notifications and thread updates. This document covers the WebSocket URL surface, the auth handshake, the message envelope, and the lifecycle of an agent conversation.
The source of truth is config/asgi.py (routing) and the three consumers under config/websocket/consumers/.
URL Surface
Three WebSocket endpoints are registered (see config/asgi.py:85-111):
| URL | Consumer | Purpose | Query parameters |
|---|---|---|---|
ws/agent-chat/ | UnifiedAgentConsumer | All AI-agent conversations (document chat, corpus chat, standalone agent chat) | corpus_id, document_id, conversation_id, agent_id (all optional, but at least one of corpus_id / document_id / agent_id is required) |
ws/thread-updates/ | ThreadUpdatesConsumer | Streaming agent-mention responses inside a discussion thread | conversation_id (required) |
ws/notification-updates/ | NotificationUpdatesConsumer | Real-time notification push (badges, moderation, agent responses) | none — keyed off the authenticated user |
The UnifiedAgentConsumer replaces three legacy consumers (DocumentQueryConsumer, CorpusQueryConsumer, StandaloneDocumentQueryConsumer) so context now flows in via query parameters instead of URL path segments.
Agent selection (UnifiedAgentConsumer)
- If
agent_idis supplied → use that specificAgentConfiguration. - Else if
document_idis supplied → use the GLOBAL default-document-agent. - Else if
corpus_idis supplied → use the GLOBAL default-corpus-agent. - Otherwise → reject the connection.
Authentication
All three consumers run behind the shared JWTAuthMiddleware (wired in config/asgi.py:120-122). It accepts:
- Auth0 JWTs when
USE_AUTH0=True. The token is extracted from theAuthorization: Bearer …header (or from atokensubprotocol fallback) and verified against the cached JWKS. - Django sessions otherwise — a
sessionidcookie is enough. - Anonymous access is allowed for connections that touch only public resources; per-message permission checks gate any access to private data.
If authentication fails, the connection is closed with code WS_CLOSE_UNAUTHENTICATED. Rate-limit violations close with WS_CLOSE_RATE_LIMITED.
Message Envelope
All messages exchanged over ws/agent-chat/ and ws/thread-updates/ follow this JSON envelope:
interface MessageData {
type: MessageType;
content: string;
data?: {
sources?: WebSocketSources[];
timeline?: TimelineEntry[];
message_id?: string;
tool_name?: string;
args?: any;
pending_tool_call?: {
name: string;
arguments: any;
tool_call_id?: string;
};
approval_decision?: string;
requesting_agent?: { slug: string; name: string }; // sub-agent attribution
error?: string;
[key: string]: any;
};
}
Core message types
| Type | Direction | Purpose |
|---|---|---|
ASYNC_START | server→client | Begins a new LLM response. data.message_id identifies the bubble. |
ASYNC_CONTENT | server→client | Streams partial content for an in-flight message. |
ASYNC_THOUGHT | server→client | Reasoning / tool-planning trace; appended to the message timeline. |
ASYNC_SOURCES | server→client | Citation sources array; enables source pin / citation UI. |
ASYNC_APPROVAL_NEEDED | server→client | Sub-agent or tool call needs human approval. Frontend shows the approval modal. data.requesting_agent (when present) attributes the request to a @mentioned sub-agent rather than the conductor. |
ASYNC_APPROVAL_RESULT | server→client | Echoes the user's approval decision back. |
ASYNC_RESUME | server→client | Resumes streaming after an approval gate. |
ASYNC_FINISH | server→client | Final content + sources + timeline; marks the message complete. |
ASYNC_ERROR | server→client | LLM/tool error; UI shows error state. |
SYNC_CONTENT | server→client | One-shot non-streaming message (errors during validation, etc.). |
The frontend reads these inside useChatAgentMessageHandler (frontend/src/components/knowledge_base/document/right_tray/useChatAgentMessageHandler.ts).
Client → server messages
The client sends a JSON payload with the user's prompt:
{ "query": "What are the key terms in this contract?" }
For approval gates, the client replies with an approval decision:
{ "approval_decision": "approve" | "deny", "tool_call_id": "…" }
Connection Lifecycle (agent-chat)
sequenceDiagram
Client->>Middleware: Connect (with JWT or session cookie)
Middleware->>Consumer: Accept (or close 4000/4001)
Consumer->>Client: Connection accepted
Client->>Consumer: {"query": "user question"}
Consumer->>Client: ASYNC_START
Consumer->>Client: ASYNC_THOUGHT (optional, repeating)
Consumer->>Client: ASYNC_CONTENT (streaming tokens)
Consumer->>Client: ASYNC_APPROVAL_NEEDED (if a tool call needs human approval)
Client->>Consumer: {"approval_decision": "approve"}
Consumer->>Client: ASYNC_RESUME
Consumer->>Client: ASYNC_SOURCES (optional)
Consumer->>Client: ASYNC_FINISH
Sub-agent delegation
When the user @mentions an agent (rich-mention agent delegation), the conductor's per-turn tool surface is rebuilt to include a delegate_to_<slug> tool for each mentioned agent. If a sub-agent's tool call needs approval, the ASYNC_APPROVAL_NEEDED frame carries requesting_agent so the approval modal attributes the request correctly. Pinned sub-agent replies arrive as separate ChatMessage rows (with agentConfiguration set) and render their own bubble + attribution chip.
thread-updates messages
ThreadUpdatesConsumer emits a different set of event types tailored to thread-side mention streaming (thread_updates.py:205-262):
agent_stream_start— sub-agent started responding to a mention.agent_stream_token— incremental token in the sub-agent's response.agent_tool_call— a tool call inside the sub-agent's turn.agent_stream_complete— sub-agent finished; a completeChatMessagerow is now visible via GraphQL.agent_stream_error— sub-agent errored out.
notification-updates messages
NotificationUpdatesConsumer (notification_updates.py:233-286) emits:
notification_created— new notification (badge award, mention, moderation action, etc.).notification_updated— read/dismissed state changed.notification_deleted— notification removed.
Error Handling
| Error class | Behaviour |
|---|---|
| Auth failure | Close with WS_CLOSE_UNAUTHENTICATED (per config/websocket/middleware.py). |
| Rate-limit exhaustion | Close with WS_CLOSE_RATE_LIMITED. |
Invalid context (no corpus_id / document_id / agent_id) | Close immediately. |
| Malformed JSON in receive | SYNC_CONTENT error message. |
| LLM context exhausted | ASYNC_ERROR with error = WS_ERROR_CONTEXT_EXHAUSTED (see opencontractserver.constants.context_guardrails). |
| LLM/tool failure | ASYNC_ERROR with data.error populated. |
Permissions & Rate Limiting
- Per-message permission checks run inside the consumer using
Model.objects.visible_to_user(user)patterns — the WebSocket connection itself is not a free pass. - Rate-limit decorators (
config/ratelimit/decorators.check_ws_rate_limit) gate connection setup and per-message throughput. - The consumer's "agent instance" is reused within a session for conversation continuity, but no state survives a disconnect — reconnection always starts a fresh handshake.