Write your own connector
You can wrap any agent — Claude Code, a Python script, a shell pipeline, your own LLM call — as a Sophon connector. The contract is small enough to read in one sitting and short enough to implement in a few hundred lines.
This is a complete walkthrough of the supported /v1/bridge/*
surface. Code samples are TypeScript, but anything that can hold
a WebSocket open and POST JSON works.
What you're building
A long-lived process with one responsibility: be the relay
between Sophon Cloud and your agent. Receive session.message
updates over WebSocket, translate them into whatever your agent
speaks, translate the reply back into SAP REST calls.
Sophon Cloud ──WS update──▶ Your bridge ──your protocol──▶ Your agent
◀──REST POSTs── ◀──your protocol──
Step 1 — get a bridge token
Pair your bridge from the iOS app one time. The flow:
POST https://api.sophon.at/v1/pairing/start
Content-Type: application/json
{
"connector_type": "my-agent",
"host_label": "serafim's mac"
}Returns a 7-letter code and a poll_token. Print the code,
then poll:
POST https://api.sophon.at/v1/pairing/poll
Content-Type: application/json
{ "poll_token": "p_…" }Until the user types the code in iOS Settings → Pair another
agent, the response is {"status": "pending"}. Once they do:
{
"ok": true,
"result": {
"status": "paired",
"installation_id": "inst_q9w8e7r6t5y4u3i2",
"token": "inst_q9w8e7r6t5y4u3i2:s_live_AbC123…"
}
}Stash the token. Don't commit it to a repo. From this point on, you only need the token.
Step 2 — open the WebSocket
import WebSocket from 'ws'
const TOKEN = process.env.SOPHON_TOKEN! // inst_…:s_live_…
const ws = new WebSocket('wss://api.sophon.at/v1/bridge/ws', {
headers: { Authorization: `Bearer ${TOKEN}` },
})
ws.on('open', () => console.log('connected'))The server sends:
{ "type": "ready", "installation_id": "inst_…" }…then nothing until the user sends a message in iOS. Heartbeat
every 30 s — respond to {"type":"ping"} with
{"type":"pong"} within 10 s.
Step 3 — listen for session.message
ws.on('message', async (raw) => {
const frame = JSON.parse(raw.toString())
if (frame.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }))
return
}
if (frame.type !== 'update') return
if (frame.update.type !== 'session.message') return
const { session, message, interaction_id } = frame.update.payload
await handleMessage({
sessionId: session.id,
interactionId: interaction_id,
text: message.text,
attachments: message.attachments ?? [],
})
// Tell the server we processed this update.
ws.send(JSON.stringify({ type: 'ack', up_to_update_id: frame.update.update_id }))
})Other update types you'll see eventually: approval.resolved
(after iOS sends back a decision), session.cancelled (user
cancelled the turn), installation.permissions_updated. See the
Wire reference.
Step 4 — open the agent bubble
The first thing your bridge does on a new turn is POST an empty agent message. iOS uses it to render a "Thinking…" placeholder the user sees instantly:
const created = await post('/v1/bridge/sendMessage', {
session_id: sessionId,
interaction_id: interactionId,
text: ' ', // empty placeholder
idempotency_key: crypto.randomUUID(),
})
const messageId = created.result.message_idmessageId is what every subsequent delta + end will reference.
Step 5 — stream the reply
For every chunk your agent emits:
for await (const chunk of myAgent.run(text)) {
await post('/v1/bridge/sendMessageDelta', {
message_id: messageId,
delta: chunk,
idempotency_key: crypto.randomUUID(),
})
}When the agent is done:
await post('/v1/bridge/sendMessageEnd', {
message_id: messageId,
text: fullText, // optional canonical text
usage: { // optional usage row
input_tokens: 12,
output_tokens: 34,
estimated_cost_usd: 0.0012,
model: 'claude-sonnet-4-5',
provider: 'anthropic',
},
finish_reason: 'stop',
idempotency_key: crypto.randomUUID(),
})iOS sees message_finalized, the bubble locks, the chat list
shows the snippet.
Step 6 — tools
When your agent invokes a tool, fan it out as task_* events:
const taskId = `task_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await post('/v1/bridge/createTask', {
session_id: sessionId,
interaction_id: interactionId,
task_id: taskId,
kind: 'bash', // or 'http', 'mcp', 'plugin', etc.
status_label: 'ls -la',
args: { command: 'ls -la' },
})
// optional progress
await post('/v1/bridge/updateTask', {
session_id: sessionId,
interaction_id: interactionId,
task_id: taskId,
progress_percent: 50,
})
// done
await post('/v1/bridge/finishTask', {
session_id: sessionId,
interaction_id: interactionId,
task_id: taskId,
status: 'completed', // | 'failed' | 'cancelled'
result: { stdout: '…', exit_code: 0 },
})iOS folds these into:
- The live deck above the input ("Running 1 tool…").
- A persistent tool card inside the assistant bubble (drill in for args + result).
task_id itself is the idempotency key — repeating the same
shape returns idempotent: true.
Step 7 — approvals (HITL)
When the agent wants permission before doing something:
const approvalId = `apr_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
await post('/v1/bridge/requestApproval', {
session_id: sessionId,
interaction_id: interactionId,
approval_id: approvalId,
action: 'shell.exec',
title: 'Run delete?',
command: 'rm -rf /tmp/foo',
host: 'localhost',
message: 'About to delete /tmp/foo. Approve?',
severity: 'high', // 'low' | 'medium' | 'high'
idempotency_key: crypto.randomUUID(),
})iOS surfaces an action sheet. The user taps Allow / Allow always / Deny. Sophon Cloud publishes the resolution to the bridge bus — your bridge sees:
{
"type": "update",
"update": {
"type": "approval.resolved",
"payload": {
"approval_id": "apr_…",
"decision": "approve", // | 'approve_always' | 'deny'
"scope": "tool", // optional, on approve_always
"scope_value": "Bash" // optional
}
}
}Resume the agent run accordingly.
A complete minimal example
This is the entire happy path in one file:
import WebSocket from 'ws'
import crypto from 'node:crypto'
const TOKEN = process.env.SOPHON_TOKEN!
const BASE = 'https://api.sophon.at'
const ws = new WebSocket(`${BASE.replace('https', 'wss')}/v1/bridge/ws`, {
headers: { Authorization: `Bearer ${TOKEN}` },
})
ws.on('message', async (raw) => {
const frame = JSON.parse(raw.toString())
if (frame.type === 'ping') return ws.send(JSON.stringify({ type: 'pong' }))
if (frame.type !== 'update') return
if (frame.update.type !== 'session.message') return
const { session, message, interaction_id } = frame.update.payload
// 1. Open the bubble.
const created = await post('/v1/bridge/sendMessage', {
session_id: session.id,
interaction_id,
text: ' ',
idempotency_key: crypto.randomUUID(),
})
const messageId = created.result.message_id
// 2. Stream the reply.
for await (const chunk of myAgent.run(message.text)) {
await post('/v1/bridge/sendMessageDelta', {
message_id: messageId,
delta: chunk,
idempotency_key: crypto.randomUUID(),
})
}
// 3. Finalise.
await post('/v1/bridge/sendMessageEnd', {
message_id: messageId,
idempotency_key: crypto.randomUUID(),
})
ws.send(JSON.stringify({ type: 'ack', up_to_update_id: frame.update.update_id }))
})
async function post(path: string, body: unknown) {
const r = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
return r.json() as any
}
declare const myAgent: { run(text: string): AsyncIterable<string> }That's the floor. From here you add: tools, approvals,
backpressure (serialise POSTs per (session_id, interaction_id)),
ack-after-success semantics, retry on 429 / 5xx, reconnect with
exponential backoff.
What iOS expects
Minimal surface for things to render correctly:
| Field | Required | Notes |
|---|---|---|
interaction_id | yes | binds delta → bubble |
message_id | yes | from sendMessage, used for delta + end |
Per-bubble text | yes | final after end is canonical |
attachments[] | no | {key, mime, size, name} — server hydrates URL |
metadata.usage | no | drives the per-bubble token / cost row |
task_id for tool calls | yes (if you have tools) | drives the ToolGroupView |
approval_id for HITL | yes (if you have approvals) | drives the ApprovalOptionsSheet |
If a field is missing, iOS doesn't render the affordance — it just falls through. Nothing breaks; the user just doesn't see, e.g., the cost row.
Deploy
- Laptop — run as a launchd / systemd service so it starts at login.
- VPS — set up a tiny Node service, point a
systemdunit at it, you're done. - Fly Machine — 1 GB free tier handles the WebSocket plus your agent; suspend on idle.
The OpenClaw bridge in
s-chat-cloud/connectors/openclaw-bridge
is ~600 lines and a complete working example — read it as a
reference. The shape will look very similar to what you write.
Read next
- Connection lifecycle — pairing, reconnect, heartbeats — the parts the example glosses over.
- Idempotency & resume — how to retry safely.
- Errors & rate limits — be a well-behaved client.
- Wire reference — every payload field.