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):

URLConsumerPurposeQuery parameters
ws/agent-chat/UnifiedAgentConsumerAll 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/ThreadUpdatesConsumerStreaming agent-mention responses inside a discussion threadconversation_id (required)
ws/notification-updates/NotificationUpdatesConsumerReal-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)

  1. If agent_id is supplied → use that specific AgentConfiguration.
  2. Else if document_id is supplied → use the GLOBAL default-document-agent.
  3. Else if corpus_id is supplied → use the GLOBAL default-corpus-agent.
  4. 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 the Authorization: Bearer … header (or from a token subprotocol fallback) and verified against the cached JWKS.
  • Django sessions otherwise — a sessionid cookie 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

TypeDirectionPurpose
ASYNC_STARTserver→clientBegins a new LLM response. data.message_id identifies the bubble.
ASYNC_CONTENTserver→clientStreams partial content for an in-flight message.
ASYNC_THOUGHTserver→clientReasoning / tool-planning trace; appended to the message timeline.
ASYNC_SOURCESserver→clientCitation sources array; enables source pin / citation UI.
ASYNC_APPROVAL_NEEDEDserver→clientSub-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_RESULTserver→clientEchoes the user's approval decision back.
ASYNC_RESUMEserver→clientResumes streaming after an approval gate.
ASYNC_FINISHserver→clientFinal content + sources + timeline; marks the message complete.
ASYNC_ERRORserver→clientLLM/tool error; UI shows error state.
SYNC_CONTENTserver→clientOne-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 complete ChatMessage row 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 classBehaviour
Auth failureClose with WS_CLOSE_UNAUTHENTICATED (per config/websocket/middleware.py).
Rate-limit exhaustionClose with WS_CLOSE_RATE_LIMITED.
Invalid context (no corpus_id / document_id / agent_id)Close immediately.
Malformed JSON in receiveSYNC_CONTENT error message.
LLM context exhaustedASYNC_ERROR with error = WS_ERROR_CONTEXT_EXHAUSTED (see opencontractserver.constants.context_guardrails).
LLM/tool failureASYNC_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.