NetworkWebSocket Protocol
DocsNetworkWebSocket Protocol

WebSocket Protocol

CONNECT to start or resume, INPUT to message. Session stays alive between executions.

Two client message types, two intents: CONNECT authenticates and restores your session. INPUT sends a prompt. That's the whole protocol.

Overview

MessageIntentWhen
CONNECT"Authenticate me, restore my session"First message on every WebSocket
INPUT"Run this prompt"After CONNECT

WebSocket lifecycle

┌────────────────────────────────────────────────────────────────┐
│                    WebSocket Lifecycle                          │
│                                                                │
│   Every connection:  WS open → CONNECT → CONNECTED → ...      │
│                                                                │
│   CONNECT carries:   auth + session (conversation history)     │
│   INPUT carries:     just the prompt (session already set)     │
│                                                                │
│   Server decides:    new / connected / running                 │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Session Lifecycle

SESSION = connection.  EXECUTION = one INPUT → OUTPUT cycle.
Session outlives executions. Multiple INPUTs per session.

State transitions

    ╭──────────╮
    │   new    │◄──── session_id not found / first connect
    ╰────┬─────╯
         │ CONNECT
         ↓
    ╭──────────╮
    │connected │── 10min idle ─► REMOVED
    │ (idle)   │
    ╰────┬─────╯
         │ INPUT
         ↓
    ╭──────────╮
    │ running  │── 1h idle (stuck) ─► REMOVED
    ╰────┬─────╯
         │ agent done
         ↓
    ╭──────────╮
    │connected │── 10min idle ─► REMOVED
    ╰──────────╯

    Two states only: 'running' (agent working) and 'connected' (idle, alive).
    WS disconnect does NOT change session.status — IO queues survive the WS,
    a reconnecting client just re-subscribes via CONNECT { last_msg_id }.

Protocol Flows

New Session

First connection — no session_id

Client                                    Server
  │                                         │
  │── WS open ────────────────────────────►│
  │                                         │
  │── CONNECT ─────────────────────────────►│  verify Ed25519 signature
  │   { auth, session: {messages} }         │  no session_id → new session
  │                                         │  store conversation history
  │                                         │
  │◄── CONNECTED ──────────────────────────│  { session_id: "abc", status: "new" }
  │                                         │
  │◄── PING ───────────────────────────────│  keep-alive starts (every 30s)
  │── PONG ────────────────────────────────►│
  │                                         │
  │── INPUT ───────────────────────────────►│  run agent with prompt
  │   { prompt: "hello" }                   │  (no session in INPUT)
  │                                         │
  │◄── thinking ───────────────────────────│  stream events
  │◄── tool_call ──────────────────────────│
  │◄── OUTPUT ─────────────────────────────│  { result, session }
  │                                         │  session → "connected" (not dead)
  │                                         │
  │── INPUT ───────────────────────────────►│  same WS, same session
  │   { prompt: "tell me more" }            │
  │◄── ... ────────────────────────────────│
  │◄── OUTPUT ─────────────────────────────│

Trust Gate (stranger onboarding)

careful trust: CONNECT interrupted, onboard completes it

Client                                    Server
  │                                         │
  │── CONNECT ─────────────────────────────►│  signature valid, but identity
  │   { auth, session_id }                  │  is a stranger → gate fires
  │                                         │  stash the CONNECT
  │                                         │
  │◄── ONBOARD_REQUIRED ───────────────────│  { methods: [invite_code, payment] }
  │                                         │
  │    (human types an invite code —        │
  │     no deadline on this wait)           │
  │                                         │
  │── ONBOARD_SUBMIT ──────────────────────►│  verify signed payload
  │   { invite_code, signed }               │  promote identity to contact
  │                                         │
  │◄── ONBOARD_SUCCESS ────────────────────│
  │                                         │  server completes the stashed
  │                                         │  CONNECT itself — the client
  │                                         │  must NOT send CONNECT again
  │◄── CONNECTED ──────────────────────────│  { session_id, status }
  │                                         │
  │── INPUT ───────────────────────────────►│  the original input resumes
  │   { prompt }                            │  and runs exactly once
  │◄── stream events / OUTPUT ─────────────│

Resume After Page Refresh (agent still running)

Reconnect to running agent

Client                                    Server
  │                                         │
  │    (agent still running on server)      │
  │                                         │
  │── WS open ────────────────────────────►│
  │                                         │
  │── CONNECT ─────────────────────────────►│  verify signature
  │   { session_id, last_msg_id, session }  │  registry.get(...) → running
  │                                         │  io.rewind_to(last_msg_id)
  │                                         │  merge sessions if server newer
  │                                         │
  │◄── CONNECTED ──────────────────────────│  { session_id, status: "running" }
  │◄── replayed events (after last_msg_id)│  pump io._msgs_from_agent
  │◄── PING ───────────────────────────────│  keep-alive resumes
  │                                         │
  │◄── stream events ─────────────────────│  live again
  │◄── OUTPUT ─────────────────────────────│

Resume After Page Refresh (agent finished)

Reconnect to idle session

Client                                    Server
  │                                         │
  │    (agent finished while client away)   │
  │                                         │
  │── WS open ────────────────────────────►│
  │                                         │
  │── CONNECT ─────────────────────────────►│  verify signature
  │   { session_id: "abc", session: {...} } │  registry.get("abc") → connected
  │                                         │  merge: server has newer data
  │                                         │
  │◄── CONNECTED ──────────────────────────│  { session_id: "abc",
  │                                         │    status: "connected",
  │                                         │    server_newer: true,
  │                                         │    session: {merged},
  │                                         │    chat_items: [...] }
  │                                         │
  │    (client updates UI with server data) │
  │                                         │
  │── INPUT ───────────────────────────────►│  ready for next prompt
  │   { prompt: "what else?" }              │
  │◄── ... ────────────────────────────────│
  │◄── OUTPUT ─────────────────────────────│

Session Not Found (expired or never existed)

Graceful fallback to new session

Client                                    Server
  │                                         │
  │── WS open ────────────────────────────►│
  │── CONNECT { session_id: "abc" } ──────►│  not in registry
  │◄── CONNECTED ──────────────────────────│  { session_id: "abc", status: "new" }
  │                                         │
  │── INPUT ───────────────────────────────►│  fresh session, full history from CONNECT

Message Reference

Client → Server

CONNECT

Authenticate, restore session, and sync conversation. Always the first message.

{
  "type": "CONNECT",
  "session_id": "550e8400-...",
  "last_msg_id": "ev-9f12...",
  "session": { "messages": [...], "mode": "safe" },
  "payload": { "to": "0x3d4017c3e843...", "timestamp": 1702234567 },
  "from": "0xClientPublicKey",
  "signature": "0x..."
}
FieldRequiredDescription
session_idNoSession to resume. Omit for new session.
sessionNoConversation history (messages, mode, etc.)
last_msg_idNoID of the last agent event the client fully rendered. On resume of a running session, server rewinds its event cursor to right after this id and replays anything missed. Omit (or null) to replay all in-flight events of the current execution.
payloadYesSigned payload for authentication
fromYesClient's public address
signatureYesEd25519 signature of payload

Server response based on state:

session_idServer stateResponse statusServer action
Not provided"new"Allocate new session
ProvidedIn registry, running"running"io.rewind_to(last_msg_id), spawn new forward task
ProvidedIn registry, connected"connected"Merge sessions, reset idle timer
ProvidedNot found"new"Allocate new session (same id)

INPUT

Send a prompt. Only valid after CONNECTED. No session data — just the prompt. If the session already has a running agent, the server routes this as runtime input (mid-execution interjection) instead of starting a second agent. The prompt is appended to the running agent's message history at the next iteration, and the server replies RUNTIME_INPUT_ACK instead of starting a new OUTPUT cycle.

{
  "type": "INPUT",
  "prompt": "Translate hello to Spanish",
  "images": ["data:image/png;base64,..."],
  "files": [{ "name": "doc.pdf", "data": "data:application/pdf;base64,..." }]
}
FieldRequiredDescription
promptYesThe user's message
imagesNoArray of base64 data URLs (passed directly to LLM as visual content)
filesNoArray of file objects (saved to disk, agent reads via tools)
File Upload Protocol

Files are sent inline as base64-encoded data URLs:

{
  "name": "report.pdf",
  "data": "data:application/pdf;base64,JVBERi0xLjQK..."
}

1. Validates against file limits (default: 10MB per file, 10 files per request)

2. Decodes base64 and saves to .co/uploads/{filename}

3. Adds file paths to the agent's message as a system reminder

4. Agent uses read_file or other tools to process the files

Images vs Files: Images are passed directly to the LLM as visual content (multimodal). Files are saved to disk and read by tools.

PONG

{ "type": "PONG" }

ASK_USER_RESPONSE

{ "type": "ASK_USER_RESPONSE", "answer": "Python 3" }

APPROVAL_RESPONSE

{ "type": "APPROVAL_RESPONSE", "approved": true, "scope": "once" }

Server → Client

CONNECTED

Response to CONNECT.

{
  "type": "CONNECTED",
  "session_id": "550e8400-...",
  "status": "new",
  "server_newer": true,
  "session": { "messages": [...] },
  "chat_items": [...]
}
statusMeaningClient action
"new"Fresh sessionSend INPUT when ready
"connected"Session alive, idleSend INPUT when ready
"running"Agent still runningWait for replayed/streaming events

server_newer, session, and chat_items are only included when the server's session data is newer than the client's.

OUTPUT

Execution completed. Session stays alive for next INPUT.

{
  "type": "OUTPUT",
  "result": "Hola",
  "session_id": "550e8400-...",
  "duration_ms": 1250,
  "session": { "messages": [...], "trace": [...], "turn": 2 }
}

PING

Keep-alive. Sent every 30 seconds.

{ "type": "PING" }

Stream Events

TypeDescription
thinkingAgent reasoning
tool_callTool execution started
tool_resultTool execution completed
ask_userAgent needs human input
approval_neededTool requires approval
plan_reviewPlan ready for review
compactContext compaction

RUNTIME_INPUT_ACK

Acknowledges an INPUT that arrived while the agent was running. The prompt has been queued and will be picked up at the agent's next iteration boundary. No new OUTPUT cycle — the original input's OUTPUT carries the agent's final response addressing both prompts.

{
  "type": "RUNTIME_INPUT_ACK",
  "session_id": "550e8400-...",
  "id": "runtime-input-7c2a..."
}

ERROR

Malformed input or protocol violations. For JSON parse errors, the server also returns the offending payload so the client can locate the bug.

{
  "type": "ERROR",
  "message": "Invalid JSON: Expecting property name enclosed in double quotes at line 2 col 5 (pos 18)",
  "received": "{type: 'INPUT', ...}"
}

Architecture

End-to-end data flow

  ╔══════════════╗                    ╔═══════════════════════════╗
  ║   oo-chat    ║                    ║     Agent Server          ║
  ║  (browser)   ║                    ║  (Python SDK + host())    ║
  ╠══════════════╣                    ╠═══════════════════════════╣
  ║              ║                    ║                           ║
  ║ localStorage ║    WebSocket       ║  ┌─────────────────────┐  ║
  ║ ┌──────────┐ ║   ┌──────────┐    ║  │ ActiveSessionRegistry│  ║
  ║ │ session  │ ║───│ /ws      │────║──│                     │  ║
  ║ │ chatItems│ ║   └──────────┘    ║  │ session_id → {      │  ║
  ║ │ messages │ ║    CONNECT ──►    ║  │   io, thread,       │  ║
  ║ └──────────┘ ║    ◄── CONNECTED  ║  │   status, last_ping │  ║
  ║              ║    INPUT ────►    ║  │ }                   │  ║
  ║ TS SDK       ║    ◄── events     ║  └─────────┬───────────┘  ║
  ║ RemoteAgent  ║    ◄── OUTPUT     ║            │              ║
  ║              ║    PING/PONG      ║            ↓              ║
  ╚══════════════╝                    ║  ┌─────────────────────┐  ║
                                      ║  │ SessionStorage      │  ║
                                      ║  │ (.co/session_       │  ║
                                      ║  │  results.jsonl)     │  ║
                                      ║  └─────────────────────┘  ║
                                      ╚═══════════════════════════╝

  Data Ownership:
  ┌────────────────────────────────────────────────────────────────┐
  │ Client owns: conversation history (localStorage)              │
  │ Server owns: execution state (registry), results (storage)    │
  │ CONNECT syncs: client → server (session), server → client     │
  │                (if server_newer)                               │
  └────────────────────────────────────────────────────────────────┘

Separation of concerns

┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│   Connection    │  │  Conversation   │  │   Execution     │
│                 │  │                 │  │                 │
│ WebSocket + auth│  │ Message history │  │ One INPUT→OUTPUT│
│ PING/PONG       │  │ Owned by client │  │ Agent thread    │
│ Persistent      │  │ Sent via CONNECT│  │ Temporary       │
│                 │  │ Merged on server│  │                 │
│ Dies: WS close  │  │ Dies: never     │  │ Dies: OUTPUT    │
│ + 10min grace   │  │ (localStorage)  │  │                 │
└─────────────────┘  └─────────────────┘  └─────────────────┘

Authentication

Authentication happens once, on CONNECT. All subsequent INPUT messages on the same WebSocket are trusted.

Auth flow

CONNECT (signed)          INPUT (not signed)
  │                          │
  ▼                          ▼
Server verifies            Server trusts
signature → OK             (same WS, already authenticated)
Trust LevelCONNECT Behavior
openAccept without signature
carefulAccept unsigned, recommend signature
strictRequire valid signature

Client Reconnect

Client-side reconnect logic

Page loads → Zustand hydrates → session_id exists?
  │
  ├── Yes → CONNECT { session_id, session: {messages} }
  │           │
  │           ├── "new"       → session expired, start fresh (client has history)
  │           ├── "connected" → session alive, ready for INPUT
  │           └── "running" → agent running, events will stream
  │
  └── No  → show empty state, wait for user input
              → CONNECT (no session_id) on first message

Protocol Evolution

v0.9.x — INIT + ATTACH

WS open → INIT { auth }    → CONNECTED { status: "new" }
         INPUT { prompt, session }  → events → OUTPUT → session dies

v0.10.x — CONNECT (unified)

WS open → CONNECT { auth, session_id? } → CONNECTED { status }
         INPUT { prompt, session }     → events → OUTPUT → session dies

v0.11.x — Session survives execution (current)

WS open → CONNECT { auth, session_id?, session }
         → CONNECTED { status: new/connected/running }

         INPUT { prompt }   → events → OUTPUT  (session stays alive)
         INPUT { prompt }   → events → OUTPUT  (again, same session)
         INPUT { prompt }   → events → OUTPUT  (and again)

WS close → 10min grace → session cleaned up

Server Console Output

Structured status lines designed for quick scanning. Routine messages are compact, data flow events are indented sub-lines.

Connection lifecycle

⚡ ws+ 127.0.0.1 (0 active)        # new WebSocket, show session count
✓ CONNECT identity=0x2f3d... session=aad5... status=new
✓ INPUT identity=0x2f3d... session=aad5... prompt=hello world...
⚡ ws- (1 active)                    # disconnect, remaining sessions

Data flow visibility

✓ CONNECT identity=0x2f3d... session=aad5... status=connected
  ↑ client session: 4 messages       # client sent history
  ↕ merged sessions (server newer)   # server had newer data

✓ CONNECT identity=0x2f3d... session=aad5... status=running
  ↻ reattaching to running agent     # reconnecting mid-execution

✓ INPUT identity=0x2f3d... session=aad5... prompt=analyze this...
  ↑ 2 images, 1 files                # client sent attachments

Errors

✗ CONNECT auth error: forbidden
✗ INPUT rejected: not authenticated (send CONNECT first)
✗ agent error: <exception message>

Key Files

FileRole
network/asgi/websocket.pyWebSocket handler — CONNECT/INPUT routing
network/host/session/active.pyActiveSessionRegistry — in-memory session tracking
network/io/websocket.pyWebSocketIO — queue bridge between async/sync
network/host/session/storage.pySessionStorage — JSONL persistence
network/host/session/merge.pySession merge conflict resolution

Star us on GitHub

If ConnectOnion saves you time, a ⭐ goes a long way — and earns you a coffee chat with our founder.