Sophon

Connection lifecycle

What happens between "user installs my bridge" and "events flow both ways". Four phases: pair, dial, receive, resume.

1. Pairing

The user runs your bridge. It needs a token, but you don't have one yet. Pairing trades a short-lived 7-letter code for a long-lived bridge token.

Bridge → server:

POST /v1/pairing/start
Content-Type: application/json
 
{
  "connector_type": "openclaw",
  "host_label": "serafim's mac"
}

Server → bridge:

{
  "ok": true,
  "result": {
    "code": "9FP9SVT",          // print this for the user
    "expires_at": 1730345700,   // 120 s from now
    "poll_token": "p_…"         // opaque; reuse on poll
  }
}

The bridge prints the code to stdout and polls POST /v1/pairing/poll with poll_token. Polling returns pending until the user enters the code in iOS, then returns the bridge token.

iOS → server (when the user types the code):

POST /v1/me/pairing/claim
Authorization: Bearer <user_session>
 
{ "code": "9FP9SVT" }

The server marks the code consumed; the next pairing/poll from the bridge returns:

{
  "ok": true,
  "result": {
    "status": "paired",
    "installation_id": "inst_q9w8e7r6t5y4u3i2",
    "token": "inst_q9w8e7r6t5y4u3i2:s_live_AbC123…"
  }
}

Stash the token in the bridge's keychain / config file. From this point on, the pairing surface is unused.

2. Token format

inst_<16 base62>:s_<env>_<32+ base62>
  • inst_<id> — installation id, also identifies the bridge.
  • s_<env>_… — secret. env is live or test.
  • Length ≥ 50 chars total.

Pass on every request:

Authorization: Bearer inst_q9w8e7r6t5y4u3i2:s_live_AbC123…

Tokens must not appear in URL paths or query strings — the server rejects URL-embedded tokens with 400 invalid_token_location.

3. Dialling the WebSocket

GET wss://api.sophon.at/v1/bridge/ws
Authorization: Bearer inst_…:s_live_…

On success the server sends:

{ "type": "ready", "installation_id": "inst_…" }

Then everything that happens in any session under this installation flows down the socket.

4. Receiving updates

Every server-pushed event arrives as one frame:

{
  "type": "update",
  "update": {
    "update_id": "12",
    "type": "session.message",
    "session_id": "ses_…",
    "interaction_id": "int_…",
    "installation_id": "inst_…",
    "created_at": "2026-05-05T12:34:56Z",
    "payload": {
      "session": { "id": "ses_…", "title": "…" },
      "message": { "text": "list my recent files", "attachments": [] }
    }
  }
}

The type you care about most is session.message — the user just sent a message. Other types: approval.resolved, session.cancelled, session.started, installation.permissions_updated. See the Wire reference for shapes.

Acknowledge updates so the server can drop them from the buffer:

{ "type": "ack", "up_to_update_id": "12" }

5. Replying with REST

In response to an update, your bridge POSTs back via the REST namespace. The most common moves:

POST /v1/bridge/sendMessage         → opens an empty agent bubble
POST /v1/bridge/sendMessageDelta    → streams a chunk of text
POST /v1/bridge/sendMessageEnd      → finalises the bubble

POST /v1/bridge/createTask          → tool starts running
POST /v1/bridge/updateTask          → progress
POST /v1/bridge/finishTask          → completed | failed | cancelled

POST /v1/bridge/requestApproval     → HITL pause

Each is documented in the Wire reference and the Tool calls & approvals page.

6. Heartbeats

The server sends {"type":"ping"} every 30 s. Your bridge must respond with {"type":"pong"} within 10 s. Three missed pongs and the server closes the socket with code 4001.

7. Reconnect

WebSockets drop. Reconnect with exponential backoff (1 s → 2 s → 4 s … capped at 30 s). On reconnect, the server replays any unacked updates from the last 5 minutes. After that, gaps are absorbed by the iOS side's history fetch on the next session open.

ws.on('close') → wait(backoff) → ws = new WebSocket(...)
                                  → server: { type: 'ready' }
                                  → server replays: { update_id: 13 }
                                                      { update_id: 14 }
                                                      …

If the token has been revoked (rotated, leaked, manually killed from iOS Settings), you get 401 invalid_token on the upgrade. Stop reconnecting and prompt the user to re-pair.

Putting it together

import WebSocket from 'ws'
 
const TOKEN = process.env.SOPHON_TOKEN!
let backoff = 1000
 
function connect() {
  const ws = new WebSocket('wss://api.sophon.at/v1/bridge/ws', {
    headers: { Authorization: `Bearer ${TOKEN}` },
  })
 
  ws.on('open', () => { backoff = 1000 })
 
  ws.on('message', (raw) => {
    const frame = JSON.parse(raw.toString())
    switch (frame.type) {
      case 'ready':  return // installation_id available
      case 'ping':   return ws.send(JSON.stringify({ type: 'pong' }))
      case 'update': return handleUpdate(ws, frame.update)
    }
  })
 
  ws.on('close', (code) => {
    if (code === 4401) return // unauthorised — re-pair
    setTimeout(connect, backoff)
    backoff = Math.min(backoff * 2, 30_000)
  })
}
 
connect()

Once your bridge is reliably reading update frames and replying with /v1/bridge/sendMessage…, you have a working connector. The rest is shaping the agent output into events.

Read Streaming model for the per-turn event order, then Write your own connector for the full glue.