WebSocket Protocol

September 5, 2026 · View on GitHub

CONNECT to start or resume, INPUT to message, EXEC to run one tool directly. Session stays alive between executions.

This is OIP 0.1, the single ConnectOnion browser protocol. co ai serves it over the authenticated /ws socket and advertises it in CONNECTED.


Rolling compatibility window

Frontend and Host deployments are not atomic. OIP 0.1 therefore follows reader-before-writer deployment:

PairRequired behaviour
descriptor-less 0.1 reader ↔ current Hostaccepted
current React ↔ descriptor-less 0.1 Hostaccepted
current React ↔ current Hostadvertised oip/0.1 accepted
unsupported protocol/versionone non-retryable error; socket closes; no reconnect loop

Within 0.1, new non-authoritative fields and events are additive. Readers ignore what they do not understand and retain generic provider/tool rendering. Identity, session ownership, modes, approvals, cancellation, terminal state, and protocol/version are authoritative: malformed or unknown values are rejected instead of guessed.

For a rename, release R reads both names; R+1 may write the new name after R is publicly pinned; the old reader remains until at least R+2 and 30 days after R. The descriptor-less reader remains through 1.7.x and may be removed no earlier than 1.8.0a1, 2026-09-15, and two previews after compatibility telemetry no longer observes it, whichever is later.

Host emits one content-free OIP_COMPAT record for CONNECT/reattach. It contains only transport=direct|relay|unknown, peer=legacy|oip/0.1|unsupported, and outcome=accepted|rejected; it never copies peer strings, prompts, credentials, addresses, session IDs, or paths.


Overview

Three client message types, three intents:

MessageIntentWhen
CONNECT"Authenticate me, restore my session"First message on every WebSocket
INPUT"Run this prompt" or runtime input mid-executionAfter CONNECT
EXEC"Run this one tool directly, no LLM"After CONNECT

If INPUT arrives while the session's agent is already running, the server treats it as runtime input (mid-execution user input) instead of starting a second agent. The new prompt is appended to the agent's message history at the next iteration, and the server replies with RUNTIME_INPUT_ACK instead of starting a new OUTPUT cycle.

EXEC is the direct-execution fast path: it runs one named tool with no LLM, no session, and no history, replying with a single EXEC_RESULT. It requires the same CONNECT auth as INPUT, and the tool is gated by the host's .co/host.yaml permission whitelist. See remote-call.md.

On any socket — direct or through the relay — the very first frame may be SEAL instead: the client offers a one-time key, the host answers SEALED_OK with its own, and every frame after that — CONNECT included — travels inside SEALED. See Sealed channel.

A fourth type, ONBOARD_SUBMIT, exists only to answer the trust gate. It is not part of the normal path — it appears only when the server interrupts CONNECT with ONBOARD_REQUIRED. See Trust Gate.

Scoped native-provider stop

PROVIDER_INTERRUPT stops one live Codex or Claude Code invocation without cancelling its enclosing agent turn. Current clients include a bounded requestId; the Host replies exactly once with:

{
  "type": "PROVIDER_INTERRUPT_ACK",
  "requestId": "…",
  "invocationId": "codex:…",
  "accepted": true
}

accepted: true means the Host owns and forwarded the exact live invocation; it is not the terminal outcome. The matching provider_invocation event with status: "cancelled" remains authoritative. A stale or invalid target returns accepted: false with the stable reason not_active or invalid_request, so the client can restore a retry action. Legacy requests without requestId retain the older no-ack behaviour during the rolling compatibility window.

Provider-native permission change

PROVIDER_PERMISSION_CHANGE selects one Host-advertised Codex or Claude Code profile for subsequent work in the exact Work Room on screen:

{
  "type": "PROVIDER_PERMISSION_CHANGE",
  "requestId": "permission-1",
  "invocationId": "codex:call-7",
  "stateRevision": 4,
  "optionId": "codex:workspace-auto",
  "confirmRisk": false
}

The authenticated requester must own the session and be its Operator. The option must exist in the latest durable invocation catalog and fit inside the outer Host mode ceiling. An elevated Full Access option additionally requires confirmRisk: true. Browser state is never authority.

An accepted request returns PROVIDER_PERMISSION_ACK with the matching request and invocation IDs, a strictly newer revision, and the complete authoritative providerPermission state. Host then streams that same revision as a canonical provider_invocation for replay and other readers. A rejection has accepted: false and one safe reason code such as stale_revision, ceiling_denied, operator_required, or confirmation_required; it never changes durable state.

When an outer mode_change also narrows one or more Work Rooms, the Host sends mode_changed followed by one canonical provider_invocation per affected Work Room. Each provider frame reflects only the transaction's final ceiling; the Host never streams an intermediate repair under the previous mode, even if the latest completed, failed, or cancelled continuation omitted its catalog.

┌────────────────────────────────────────────────────────────────┐
│                    WebSocket Lifecycle                          │
│                                                                │
│   Every connection:  WS open → CONNECT → CONNECTED → ...      │
│                                                                │
│   CONNECT carries:   auth + session + signed-command capability│
│   INPUT carries:     signed prompt/attachments (session set)   │
│                                                                │
│   Server decides:    new / connected / running                 │
│                      …or, for a caller the trust policy denies │
│                      but offers a way in: ONBOARD_REQUIRED,    │
│                      and CONNECT waits until they pass         │
│                                                                │
└────────────────────────────────────────────────────────────────┘

Session Lifecycle

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

    ╭──────────╮
    │   new    │◄──────────────────── session_id not found
    ╰────┬─────╯
         │ CONNECT

    ╭──────────────╮
    │  connected   │◄── agent done (OUTPUT)
    ╰──────┬───────╯
           │ INPUT

    ╭──────────────╮
    │   running    │── agent working (LLM → tools → LLM)
    ╰──────┬───────╯
           │ agent done

    ╭──────────────╮
    │  connected   │── 10min idle → removed
    │   (idle)     │
    ╰──────────────╯


    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 }.

    Cleanup: 'connected' after 10min idle, 'running' after 1h (stuck-agent cap).

Protocol Flows

New Session

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 ─────────────────────────────│

Resume After Page Refresh (agent still running)

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

Resume After Page Refresh (agent finished)

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)

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

Trust Gate (onboarding)

An agent whose trust policy denies strangers can still let them earn their way in — an invite code, or a payment. That negotiation happens inside CONNECT, before any message is sent.

Client                                    Server
  │                                         │
  │── WS open ────────────────────────────►│
  │── CONNECT ─────────────────────────────►│  verify Ed25519 signature — OK
  │   { payload, from, signature }          │  trust policy: this caller is denied
  │                                         │  …but onboard methods are configured
  │                                         │  stash the CONNECT, do not answer it yet
  │◄── ONBOARD_REQUIRED ───────────────────│  { methods: ["invite_code"] }
  │                                         │
  │   (client shows a code prompt)          │
  │                                         │
  │── ONBOARD_SUBMIT ──────────────────────►│  signed again, invite_code in payload
  │   { payload: { invite_code }, … }       │  verify_invite()
  │                                         │
  │◄── ONBOARD_SUCCESS ────────────────────│  { level: "contact" }  ← caller promoted
  │◄── CONNECTED ──────────────────────────│  the stashed CONNECT, now resumed
  │                                         │
  │── INPUT ───────────────────────────────►│  the conversation the caller came for

Three things follow from this shape, and each one is a mistake a client can make:

The answer arrives before the first message. A client that opens the socket on a landing page — to receive DASHBOARD_SNAPSHOT, say — already has the gate's answer in hand before the reader types anything. There is no need to send a message and watch it be refused, and no need to guess from /info: that endpoint is anonymous and tells an admin exactly what it tells a stranger, so a client that gates on it puts a code prompt in front of people who hold the keys.

A refused code is an ERROR, not another ONBOARD_REQUIRED. The gate does not re-ask. The reply is {"type": "ERROR", "message": "Invalid invite code"}, and a client that waits for a second ONBOARD_REQUIRED to detect the refusal will wait forever, leaving the reader staring at a form that never responds.

The stashed CONNECT is resumed by the server, not replayed by the client. Do not send CONNECT again after ONBOARD_SUCCESS. Its signature carries a timestamp with a five-minute window, and a human reading a card, finding a code and typing it can easily outlast that — the resend would be rejected as expired. The server holds the original and completes it itself, with the address the onboard verified.

A real capture of a first-time visitor on a gated agent, all on one socket:

ONBOARD_REQUIRED → ONBOARD_SUCCESS → CONNECTED → AGENT_PROFILE
→ DASHBOARD_SNAPSHOT → (INPUT) → … → OUTPUT → SESSION_STATUS

See ../features/trust.md for configuring onboard in trust.md.


Message Reference

Client → Server

CONNECT

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

{
  "type": "CONNECT",
  "session_id": "550e8400-...",
  "session": { "messages": [...], "mode": "default" },
  "last_msg_id": "ev-9f12...",
  "payload": {
    "to": "0x3d4017c3e843...",
    "timestamp": 1702234567,
    "signed_commands": 1
  },
  "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 the client missed. Omit (or pass null) to replay all in-flight events of the current execution.
payloadYesSigned payload for authentication
fromYesClient's public address
signatureYesEd25519 signature of payload

payload.signed_commands: 1 is itself signed. It opts the connection into the v2 command gate described below. A new server continues accepting a v1 CONNECT without it, so an older client is not stranded; it does not receive v2's per-command injection/replay protection.

Server response based on state:

session_idServer stateResponse statusServer action
Not provided"new"Allocate new session
ProvidedIn registry, running"running"Reattach IO, pipe buffered events
ProvidedIn registry, connected"connected"Merge sessions, reset idle timer
ProvidedNot found"new"Allocate new session (same id)
ProvidedOwned by another caller"new"Allocate new session, new id

A session belongs to whoever started it. Naming someone else's id gets a fresh session with a different id, reported in CONNECTED — not theirs, and not an error, which would confirm the session exists. Keeping the requested id would mean your turn overwrote their history.

CONNECTED is not the only possible reply. If the trust policy turns this caller away and the agent offers a way in, the server answers ONBOARD_REQUIRED instead and holds the CONNECT open until the caller passes — see Trust Gate below. If the policy turns them away and offers nothing, the reply is ERROR.

"Offers a way in" means a door that actually opens, not one that is merely written down. The shipped policy declares invite_code: [$CO_INVITE_CODE] and payment: $CO_PAYMENT; with neither set, nothing resolves and there is no onboarding to offer, so a stranger gets ERROR from the policy rather than an ONBOARD_REQUIRED leading nowhere.

A signature is single-use. Replaying a captured CONNECT is refused with ERROR unauthorized: this CONNECT was already used. A v2 client also signs every application command; replaying one is refused with signed command already used. The one-use ledger is shared across ASGI workers and survives a worker restart; it stores only short-lived signature digests in .co/replay.sqlite3. Each digest remains until its signed timestamp is cryptographically expired; an unavailable or locked ledger fails closed. CONNECT processing verifies Ed25519 first, atomically claims the digest second, and only then evaluates trust or onboarding policy. A replay therefore cannot repeat an LLM policy call or a policy side effect.

A v2 command signs what the server executes. Its payload contains type, all command fields, to, timestamp, and a random nonce. The server verifies the signer is the caller that opened this connection, verifies the recipient and type, then discards the unsigned compatibility copy and dispatches the signed payload. INPUT, EXEC, runtime input, approval responses and ask-user responses all pass through this gate. PONG is a transport frame. SESSION_STATUS uses the verified CONNECT identity on a live socket, or an independently signed v2 frame on a temporary socket, and only reveals a session owned by that identity. ONBOARD_SUBMIT and ADMIN frames retain their existing independent signatures.

This decision is made per caller, after the signature is verified, so an admin, a contact, or anyone who onboarded earlier never sees the gate at all.

INPUT

Send a prompt. Only valid after CONNECTED. No session data — just the prompt.

{
  "type": "INPUT",
  "prompt": "Translate hello to Spanish",
  "images": ["data:image/png;base64,..."],
  "files": [{ "name": "doc.pdf", "data": "data:application/pdf;base64,..." }],
  "payload": {
    "type": "INPUT",
    "input_id": "7c2a...",
    "prompt": "Translate hello to Spanish",
    "images": ["data:image/png;base64,..."],
    "files": [{ "name": "doc.pdf", "data": "data:application/pdf;base64,..." }],
    "to": "0x3d4017c3e843...",
    "timestamp": 1702234567,
    "nonce": "550e8400-..."
  },
  "from": "0xClientPublicKey",
  "signature": "0x..."
}

The command fields remain at the top level only so a v2 client can talk to a v1 host. A v2 host executes the verified payload, never those duplicates.

If sent while the session's agent is already running, this message is routed as runtime input: the prompt is appended to the running agent's message history (with framing telling the LLM to treat it as additional context, not a replacement) and the server replies RUNTIME_INPUT_ACK instead of starting a new OUTPUT cycle. No new thinking chat item is created — the existing one keeps streaming.

EXEC

Run one registered tool directly — no LLM, no session, no history. Only valid after CONNECTED. The server replies with a single EXEC_RESULT.

{
  "type": "EXEC",
  "exec_id": "7c2a...",
  "tool": "bash",
  "args": { "command": "co status" },
  "payload": {
    "type": "EXEC",
    "exec_id": "7c2a...",
    "tool": "bash",
    "args": { "command": "co status" },
    "to": "0x3d4017c3e843...",
    "timestamp": 1702234567,
    "nonce": "550e8400-..."
  },
  "from": "0xClientPublicKey",
  "signature": "0x..."
}

The tool is checked against the host's .co/host.yaml permission whitelist (the same list the LLM approval flow uses); a tool that isn't whitelisted comes back as an EXEC_RESULT with status: "error". Each EXEC runs as its own server-side task, so a slow tool never blocks the connection, and exec_id correlates the reply — several EXECs can be pipelined on one socket.

PONG

{ "type": "PONG" }

ASK_USER_RESPONSE

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

APPROVAL_RESPONSE

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

Approval responses are consumed once and are bound to the currently pending request.

mode_change

An authenticated client selects one Host-advertised permission mode:

{
  "type": "mode_change",
  "mode": "auto"
}

The request is accepted only while the durable session is idle and owned by the authenticated caller. read-only and auto are always available; full-access is offered only under a positive Host launch ceiling. Every authenticated participant receives the same available modes. No client field can supply or extend Full access turns. Success is mode_changed and means the durable commit completed; busy, policy, ownership, and persistence failures return ERROR. @connectonion/react owns this browser operation; O Chat consumes it without constructing protocol frames. Plan is not a mode; Todo List progress carries no authority.

ONBOARD_SUBMIT

Pass the trust gate. Sent in reply to ONBOARD_REQUIRED, on the same socket.

{
  "type": "ONBOARD_SUBMIT",
  "payload": {
    "invite_code": "B7HSW-6Y6P4-BZC5Z",
    "to": "0x3d4017c3e843...",
    "timestamp": 1702234567
  },
  "from": "0xClientPublicKey",
  "signature": "0x..."
}
FieldRequiredDescription
payload.invite_codeOne of the twoA code the agent's trust.md lists under onboard.invite_code
payload.paymentOne of the twoAmount claimed paid, for onboard.payment. This is an assertion the host verifies — nothing is charged over this socket
payload.timestampYesSame five-minute window as CONNECT. Signed fresh here, which is the point: the original CONNECT's may have aged out while the reader typed
from / signatureYesSigned exactly like CONNECT

Sent on the same socket as the CONNECT it answers. A wrong code comes back as ERROR and the stashed CONNECT is kept, so the reader can simply try again — no reconnect needed.

SEAL / SEALED_OK / SEALED {#sealed-direct-channel}

End-to-end encryption for a socket, direct or relayed. A host may announce plain ws://IP:port and needs no domain, certificate or TLS front, and a session through the relay is opaque to the relay. Before this a signed CONNECT captured on a plaintext link could be replayed inside its five-minute window (#649), direct connections were therefore limited to TLS or loopback, and the relay — which terminates TLS — read every frame it forwarded.

Handshake, first two frames on the socket:

{"type": "SEAL", "to": "0xHOST", "from": "0xCLIENT",
 "ephemeral": "<hex X25519 public key, one-time>", "timestamp": 1756800000,
 "signature": "<Ed25519 over the canonical JSON of the other five fields, by 0xCLIENT>"}

{"type": "SEALED_OK", "to": "0xCLIENT", "from": "0xHOST",
 "ephemeral": "<hex X25519 public key, one-time>", "client_ephemeral": "<the SEAL's key>",
 "signature": "<Ed25519 over the canonical JSON of the other five fields, by 0xHOST>"}

Both sides derive one NaCl Box from the two one-time keys. The address is the Ed25519 public key, so each side verifies the other's signature with nothing but the address it already had; no directory, and the relay is not involved. A SEAL older than the CONNECT freshness window, addressed to another host, or signed by someone other than from is answered with ERROR seal refused: … and the socket is closed (code 4003) — no plaintext second try.

Through the relay the frames are the same. The relay proxy reads to from the first frame to pick the agent and forwards every frame after it verbatim, adding only session_id; SEAL carries to, so nothing on the relay changes. The relay's own frames to the client — its 30s PING and an ERROR such as Agent not connected — arrive in the clear and are passed up as-is; they hold no key and carry nothing a peer said. Everything else on a sealed socket must open.

Every later frame in either direction:

{"type": "SEALED", "n": 7, "c": "<base64 ciphertext>"}

n is a per-direction counter starting at 1; the nonce is the direction tag plus n, so a captured frame replayed or reordered fails to open and ends the session. Inside c is the ordinary frame (CONNECT, INPUT, EXEC, PING/PONG, PROXY_STREAM…), and the router never sees the difference. Signed CONNECT and v2 command signatures are still required inside the seal: the seal makes the link private, the signatures still say who is speaking.

Inside a seal the CONNECT (and an ONBOARD_SUBMIT) must be signed by the identity that signed the SEAL; a frame from anyone else is refused as unauthorized: … not signed by the sealed peer. That binding is what makes the host's one-use signature ledger unnecessary on a sealed socket: nobody but the sealed peer can put a frame on it, so a captured signature cannot be presented there by anyone else, and the ledger is not consulted. A bare socket — an older client — is still held to the ledger. A co host process runs one worker and keeps that ledger in memory; only create_app() served with several uvicorn workers keeps it in .co/replay.sqlite3, and that file now heals if it is removed under a running host (#1403).

Client rule (_open_best_connection): every socket, direct or relayed, is offered a SEAL when the client has keys. A direct host that does not answer SEALED_OK is used bare only if the link is already private — TLS or loopback; otherwise the socket is closed and the client moves on to the relay. A relay host that does not answer (a 1.8.0 host) has already consumed that socket's first frame, so the client closes it and opens a fresh bare relay socket — TLS to the relay, every client's footing before 1.8.1. PROXY_ATTACH still requires a direct socket; a sealed plaintext one qualifies.

PROXY_ATTACH

Lend this computer's internet connection to the host (co proxy share). Sent once per socket after a signed CONNECT, on a direct connection only — the relay never carries page bytes. Signed like every other command.

{
  "type": "PROXY_ATTACH",
  "payload": {
    "grant": {
      "type": "proxy_grant", "grant_id": "pxg_...",
      "grantor": "0xLaptop", "holder": "0xHost", "scope": "public_internet",
      "expires_at": "2026-09-03T10:00:00Z", "max_bytes": null,
      "signature": "..."
    },
    "to": "0xHost", "timestamp": 1702234567, "nonce": "..."
  },
  "from": "0xLaptop",
  "signature": "0x..."
}

The host verifies the grant (it must name this host as holder, be unexpired, and be signed by the identity on this socket), requires contact-or-better trust, and answers PROXY_ATTACHED or ERROR. A later attach from the same identity replaces the earlier one; the attachment ends when the socket closes.

PROXY_STREAM

One multiplexed stream operation, in either direction, while a share is attached. The host opens streams; the laptop answers them.

{"type": "PROXY_STREAM", "payload": {"id": 7, "op": "connect", "address": "93.184.216.34", "port": 443}}
opDirectionFieldsMeaning
resolvehost → laptophost, portresolve this name with the laptop's DNS and policy
resolvelaptop → hostaddressesthe complete answer set
connecthost → laptopaddress, portopen a socket to this numeric address, re-classified on the laptop
connectlaptop → hostthe socket is open
databothdata (base64, ≤ 32 KiB)bytes on the stream
eofbothhalf-close: no more bytes this way
closeboththe stream is finished; forget it
errorbothcodethe request failed (EGRESS_* / DESTINATION_* codes)

Laptop → host frames are signed like every command. Host → laptop frames carry no signature: they travel inside the TLS session the laptop opened to an endpoint whose identity it already verified. At most 64 streams per share; the grant's expires_at and max_bytes are enforced by the host.

Server → Client

CONNECTED

Response to CONNECT.

{
  "type": "CONNECTED",
  "session_id": "550e8400-...",
  "status": "new",
  "protocol": {"name": "oip", "version": "0.1"},
  "session_modes": {
    "currentModeId": "auto",
    "turnsLeft": null,
    "availableModes": [
      {"id": "read-only", "name": "Read only"},
      {"id": "auto", "name": "Auto"},
      {"id": "full-access", "name": "Full access"}
    ]
  },
  "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 events/OUTPUT

server_newer, session, and chat_items are only included when the server's session data is newer than the client's (e.g., agent completed while client was away). session_modes is the authoritative current/available state for this authenticated identity when Host mode policy is enabled.

OUTPUT

Execution completed. Session stays alive for next INPUT.

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

The session may contain a canonical plan array. It is current replacement state, not a transcript entry, and is preserved across session sync, reconnect, and final output.

plan

After a successful TodoList state change, the Host sends one complete plan:

{
  "type": "plan",
  "entries": [
    {"content": "Run tests", "priority": "high", "status": "in_progress"},
    {"content": "Update docs", "priority": "medium", "status": "pending"}
  ]
}

Every update replaces the complete plan; an empty entries list clears it. The plan has no message or plan ID. The event is observational and cannot grant execution permission or change the session mode.

EXEC_RESULT

Reply to an EXEC. exec_id echoes the request. result is the tool's raw output — text, or a base64 data URL for a screenshot tool.

{
  "type": "EXEC_RESULT",
  "exec_id": "7c2a...",
  "tool": "bash",
  "status": "success",
  "result": "...raw output...",
  "duration_ms": 42
}

On failure (tool raised, not whitelisted, unknown tool): status: "error" with an error field instead of result.

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
planComplete observational TodoList replacement
compactContext compaction

AGENT_PROFILE

What the agent is — name, model, tools, every skill, and the account balance for managed-key agents. Sent once, right after CONNECTED.

{
  "type": "AGENT_PROFILE",
  "session_id": "550e8400-...",
  "name": "my-agent",
  "address": "0x3d4017c3...",
  "model": "co/gemini-3.8-flash",
  "tools": ["search", "shell"],
  "skills": [
    {"name": "co-browser", "description": "drive a browser", "location": "project"},
    {"name": "my-notes", "description": "personal", "location": "user"}
  ],
  "balance_usd": 25.34
}

This is the authenticated answer, and it is deliberately larger than the public one. GET /info and the relay directory are reachable by anyone and publish only skills from the project tree (project, claude-project); the operator's personal skills in ~/.co/skills and ~/.claude/skills stay private there. This frame arrives past the signature check and the trust gate, so it carries all of them.

A client that has not connected — or has not passed onboarding — should show the public answer and not treat it as an incomplete version of this one. It is what that viewer is entitled to see.

React package: agent.profile and useAgentForHuman().profile, null until the frame lands.

There is a third profile surface, and it is not this one. host() also sends a profile to the relay inside its ANNOUNCE frame — that is what registers the agent and puts it in the public directory, and it is built separately by _build_agent_profile() in network/host/server.py. Same public skill subset as /info, different code path, different size limits, enforced by the relay rather than by the agent. If an agent starts cleanly but reads as offline, that is the surface to look at: the relay rejects the whole ANNOUNCE when the profile fails validation, and host() prints the reason as Relay error: <reason> and keeps heartbeating. The contract is documented in oo-api/docs/relay-announce-profile.md.

DASHBOARD_SNAPSHOT

The agent's Control Center HTML (customized through the compatible dashboard.html filename) for the client to render beside chat. Sent right after CONNECTED so the Control Center paints before any input, and again after OUTPUT when the run changed the file. Agents without a dashboard.html never send it, and the frame is skipped when the file hasn't changed since this connection last saw it.

{
  "type": "DASHBOARD_SNAPSHOT",
  "html": "<!DOCTYPE html>…",
  "session_id": "550e8400-..."
}

The HTML is agent-authored and untrusted: clients render it in a sandboxed iframe with scripting and network access blocked. Files over 2MB are not sent. See dashboard.md.

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.

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

ONBOARD_REQUIRED

The trust gate, in reply to CONNECT. The caller's signature checked out, but the policy denies them — and the agent offers a way in. The CONNECT is held open, not failed.

{
  "type": "ONBOARD_REQUIRED",
  "identity": "0xCallerPublicKey",
  "methods": ["invite_code", "payment"],
  "payment_amount": 10,
  "payment_address": "0xAgentAddress"
}
FieldPresentDescription
identityAlwaysThe address that was just authenticated — the caller, echoed back
methodsAlwaysWhich of invite_code / payment this agent accepts. Show only these
payment_amountWith paymentAmount, from onboard.payment in trust.md
payment_addressWith paymentWhere to send it

Answer with ONBOARD_SUBMIT. Note what is not here: no session, no status. Nothing has been established yet.

ONBOARD_SUCCESS

The submitted proof was accepted and the caller has been promoted. CONNECTED follows on its own — the server completes the CONNECT it stashed, so do not send CONNECT again.

{
  "type": "ONBOARD_SUCCESS",
  "identity": "0xCallerPublicKey",
  "level": "contact",
  "message": "Invite code verified. You are now a contact."
}

level is the trust level actually granted, read back from the policy after promotion — not a value the client chose. From here the connection proceeds exactly as an ungated one.

ADMIN_RESULT

Reply to ADMIN_PROMOTE / ADMIN_DEMOTE from an admin caller.

{ "type": "ADMIN_RESULT", "action": "promote", "ok": true, "level": "whitelisted" }

Fields beyond action are whatever the trust handler returned for that operation.

ERROR

{ "type": "ERROR", "message": "Something went wrong" }

Also how a refused onboard comes back — {"type": "ERROR", "message": "Invalid invite code"}. There is no dedicated failure frame, and no repeat of ONBOARD_REQUIRED: a client waiting for one of those to detect the refusal will wait forever.

PROXY_ATTACHED

The share offered by PROXY_ATTACH is accepted and registered under the sender's address. A refused attach is an ERROR whose message starts with proxy attach refused:.

{ "type": "PROXY_ATTACHED", "expires_at": "2026-09-03T10:00:00Z", "max_bytes": null }

From here the host sends PROXY_STREAM frames (unsigned, see above) down this socket until it closes.


Architecture Diagram

════════════════════════════════════════════════════════════════════

  ╔══════════════╗                    ╔═══════════════════════════╗
  ║   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.

CONNECT (signed)          INPUT (not signed)
  │                          │
  ▼                          ▼
Server verifies            Server trusts
signature → OK             (same WS, already authenticated)

Trust levels:

Trust LevelCONNECT Behavior
openAccept without signature
carefulAccept unsigned, recommend signature
strictRequire valid signature

Client Reconnect

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

Before vs After

Before (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

The WebSocket handler prints structured status lines to the server console. 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 active session count
✓ CONNECT identity=0x2f3d... session=aad5... status=new
✓ INPUT identity=0x2f3d... session=aad5... prompt=hello world...
⚡ ws- (1 active)                    # disconnect, show remaining sessions

Data flow visibility

When client data is accepted, merged, or reattached, indented sub-lines show what's happening:

✓ CONNECT identity=0x2f3d... session=aad5... status=connected
  ↑ client session: 4 messages       # client sent conversation history
  ↕ merged sessions (server newer)   # server had newer data, merged
✓ CONNECT identity=0x2f3d... session=aad5... status=running
  ↻ reattaching to running agent     # reconnecting to in-progress execution
✓ INPUT identity=0x2f3d... session=aad5... prompt=analyze this...
  ↑ 2 images, 1 files                # client sent attachments

What's suppressed

Routine message types that already have their own status lines don't print a generic ← WS recv: line:

  • CONNECT, INPUT, SESSION_STATUS, PONG

Non-routine types still print:

← WS recv: ONBOARD_SUBMIT
← WS recv: ADMIN_PROMOTE

Error lines

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

Key Files

FileRole
network/host/ws_router/4-file message router package — session.py (run_ws_session main loop), connect.py (handle_connect), agent_io.py (start_agent / resume_forwarding / forwarding), ping.py (keepalive)
network/asgi/websocket.pyASGI adapter — wraps ASGI primitives into send_msg/recv_msg for ws_router
network/relay.pyRelay adapter — wraps asyncio.Queue/relay WS into send_msg/recv_msg for ws_router
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