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