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
| Message | Intent | When |
|---|---|---|
| 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 CONNECTMessage 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..."
}| Field | Required | Description |
|---|---|---|
| session_id | No | Session to resume. Omit for new session. |
| session | No | Conversation history (messages, mode, etc.) |
| last_msg_id | No | ID 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. |
| payload | Yes | Signed payload for authentication |
| from | Yes | Client's public address |
| signature | Yes | Ed25519 signature of payload |
Server response based on state:
| session_id | Server state | Response status | Server action |
|---|---|---|---|
| Not provided | — | "new" | Allocate new session |
| Provided | In registry, running | "running" | io.rewind_to(last_msg_id), spawn new forward task |
| Provided | In registry, connected | "connected" | Merge sessions, reset idle timer |
| Provided | Not 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,..." }]
}| Field | Required | Description |
|---|---|---|
| prompt | Yes | The user's message |
| images | No | Array of base64 data URLs (passed directly to LLM as visual content) |
| files | No | Array 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": [...]
}| status | Meaning | Client action |
|---|---|---|
| "new" | Fresh session | Send INPUT when ready |
| "connected" | Session alive, idle | Send INPUT when ready |
| "running" | Agent still running | Wait 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
| Type | Description |
|---|---|
| thinking | Agent reasoning |
| tool_call | Tool execution started |
| tool_result | Tool execution completed |
| ask_user | Agent needs human input |
| approval_needed | Tool requires approval |
| plan_review | Plan ready for review |
| compact | Context 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 Level | CONNECT Behavior |
|---|---|
| open | Accept without signature |
| careful | Accept unsigned, recommend signature |
| strict | Require 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 messageProtocol Evolution
v0.9.x — INIT + ATTACH
WS open → INIT { auth } → CONNECTED { status: "new" }
INPUT { prompt, session } → events → OUTPUT → session diesv0.10.x — CONNECT (unified)
WS open → CONNECT { auth, session_id? } → CONNECTED { status }
INPUT { prompt, session } → events → OUTPUT → session diesv0.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 upServer 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
| File | Role |
|---|---|
| network/asgi/websocket.py | WebSocket handler — CONNECT/INPUT routing |
| network/host/session/active.py | ActiveSessionRegistry — in-memory session tracking |
| network/io/websocket.py | WebSocketIO — queue bridge between async/sync |
| network/host/session/storage.py | SessionStorage — JSONL persistence |
| network/host/session/merge.py | Session merge conflict resolution |
ConnectOnion