Sophon

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_id

messageId 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:

FieldRequiredNotes
interaction_idyesbinds delta → bubble
message_idyesfrom sendMessage, used for delta + end
Per-bubble textyesfinal after end is canonical
attachments[]no{key, mime, size, name} — server hydrates URL
metadata.usagenodrives the per-bubble token / cost row
task_id for tool callsyes (if you have tools)drives the ToolGroupView
approval_id for HITLyes (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 systemd unit 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