# Sophon — full documentation snapshot

> Generated 2026-05-06 from https://docs.sophon.at.


---
URL: https://docs.sophon.at/quickstart/

# Quickstart

The five-minute path: iOS app on your phone, OpenClaw running on
your Mac, paired and chatting. No code to write, no SDK to install.

## What you need

- An iPhone running iOS 17 or later.
- A Mac (or Linux box reachable on the same network) where OpenClaw
  can run.
- Node 22+ on that machine — the bridge ships as `npx @sophonai/openclaw`.

## 1. Install the iOS app

Grab the build from TestFlight (link coming with v0.2 — for now,
build from `s-chat/Sophon.xcodeproj` in Xcode and run on a paired
iPhone).

Open the app, sign in with Google. You'll land on the chat list,
empty.

## 2. Run OpenClaw on your Mac

```sh
npx @sophonai/openclaw
```

The CLI does three things, in order:

1. Connects to your local OpenClaw gateway on `ws://localhost:18789`
   (override with `--openclaw-url`).
2. Asks Sophon Cloud for a 7-letter pairing code.
3. Prints the code and waits.

Output looks like:

```
✓ connected to OpenClaw at ws://localhost:18789
→ pairing code: 9FP9SVT  (valid 120s)
  enter it in the iOS app: Settings → Pair another agent
```

## 3. Pair from the phone

In the iOS app: **Settings → Pair another agent → enter the 7
letters → tap Pair.**

The bridge prints `✓ paired — installation inst_…` and stays
running. iOS adds **My OpenClaw** to your chat list.

## 4. First chat

Tap the compose pencil → pick **My OpenClaw** from the chip strip
→ send "list my recent files".

You should see, in order:

- Your message bubble appears immediately.
- A **"Thinking…"** placeholder bubble for the agent.
- A live tool card: *"Running ls -la"*.
- An **approval prompt** if OpenClaw is in restricted mode — tap
  *Allow once*.
- The reply streams in token by token.
- The chat list lights up the new chat with the latest snippet.

Kill the app, reopen it. The chat is still there, the bubble is
fully reconstructed including the tool card.

## What just happened

```
iOS  ──HTTPS──▶  /v1/me/sessions/:id/send         (your message)
                   │
                   │  push to bridge bus
                   ▼
            api.sophon.at  ──WS──▶  npx @sophonai/openclaw
                                          │
                                          ▼
                                     OpenClaw gateway
                                     (chat.send RPC)
                                          │
                                          │  stream:'assistant' deltas
                                          ▼
                ◀──/v1/bridge/sendMessageDelta──
                                          │
                ◀──/v1/bridge/sendMessageEnd──
                   │
                   │  SSE message_delta + message_finalized
                   ▼
                 iOS UI
```

Everything you saw is documented field-by-field in the
[Wire reference](/protocol/wire/).

## What&apos;s next?

- Read [**Concepts**](/concepts/) to internalise the eight nouns
  (installation, session, interaction, tool call, approval, …) the
  rest of the docs lean on.
- Read [**Write your own connector**](/connectors/custom/) when
  you&apos;re ready to wire your own agent — anything you can hold a
  WebSocket from will work.
- Skim the [**Protocol overview**](/protocol/) for the full picture.


---
URL: https://docs.sophon.at/concepts/

# Concepts

Sophon has a small vocabulary. Internalising these eight nouns now
makes the rest of the docs read smoothly.

## The mental model

```
   ┌─────────┐   SSE     ┌──────────────┐    WS+REST   ┌──────────┐
   │  iOS    │ ◀─────────│              │ ◀──────────▶ │  Bridge  │
   │  app    │           │ Sophon Cloud │              │          │
   │  (you)  │ ─────────▶│ api.sophon   │              │ (the     │
   └─────────┘  /v1/me   └──────────────┘  /v1/bridge  │  relay)  │
                                                       └────┬─────┘
                                                            │ stdio,
                                                            │ HTTP, WS
                                                       ┌────▼─────┐
                                                       │  Agent   │
                                                       │ (your    │
                                                       │  code)   │
                                                       └──────────┘
```

The phone never talks to your agent directly. It talks to **Sophon
Cloud**. The cloud talks to the **bridge** you paired. The bridge
talks to your **agent**.

## Agent

The actual program doing the work. A Claude wrapper, a Python
script, a shell pipeline, your own LLM call — it&apos;s whatever
generates the reply. Lives on **your** machine. Sophon never sees
it; we only see what your bridge forwards.

## Bridge / Connector

The small relay that translates your agent into SAP. We use the two
words almost interchangeably. **OpenClaw** (`npx @sophonai/openclaw`)
is our reference bridge — it wraps a local OpenClaw gateway. If you
want to bring something else, you write a custom connector against
the protocol.

A bridge has exactly one job: hold a WebSocket open to Sophon
Cloud, listen for `session.message` updates, and POST back the
agent&apos;s reply.

## Installation

One paired bridge on your account, identified by an ID like
`inst_q9w8e7r6t5y4u3i2`. Each pairing creates one installation.
You can have many — work Mac, home Mac, a VPS in Frankfurt, all
under the same Google account. They show up as separate connections
in iOS, distinguished by name and emoji.

When you "uninstall" or rotate a token, you&apos;re acting on the
installation.

## Session

A chat thread inside one installation. Visible in the iOS chat list.
A session has an ID like `ses_a1b2c3d4e5f6g7h8`, a title, and a
state (`active`, `archived`, or soft-deleted). Sessions are scoped
to one installation — you can&apos;t move a chat from your work-Mac
OpenClaw to your home-Mac OpenClaw.

## Interaction

One user-message-then-agent-reply cycle inside a session. Each one
has an `interaction_id` like `int_p0o9i8u7y6t5r4e3`. Every event
the bridge emits during the cycle (message deltas, tool calls,
approvals) carries that ID, so the iOS client can route deltas to
the right bubble.

Interactions are how we resume after a network blip. If your phone
drops mid-stream, it reconnects, the server replays the missing
events on the same `interaction_id`, the bubble continues filling
in.

## Message

What you see in a bubble. Each has a `message_id`, a role
(`user` or `agent`), and a text body. Messages stream as deltas
during an interaction and finalise on `message_end`.

A user message also carries optional attachments (images, files).
An agent message carries optional usage metadata (input/output
tokens, model, estimated cost).

## Tool call (Task)

One tool invocation by the agent. Identified by a `task_id` (often
the underlying provider&apos;s call id, e.g. Anthropic&apos;s
`call_…`). Lifecycle:

```
created  →  progress*  →  completed | failed | cancelled
```

iOS renders running tasks as a card in the bubble — collapsible,
shows args + result. Multiple tasks in one interaction collapse
into a *"Ran 3 commands"* row above the input.

## Approval

Human-in-the-loop pause. The agent says "may I run this?", iOS
shows an action sheet, you tap Allow / Allow always / Deny. The
decision flows back to the bridge, which unblocks the agent.

Each approval has an `approval_id`, a severity, a command preview,
and an `expires_at` (default 5 minutes). You can grant
*allow-always* scoped to a session, a tool name, or a domain;
later requests matching the same scope auto-resolve.

---

## How they compose

A typical chat looks like this:

```
Installation:  inst_…       (one paired OpenClaw on your Mac)
  └ Session:   ses_…        ("Refactor the Sophon iOS adapter")
      ├ Interaction int_a   ("rewrite the tool-rendering")
      │   ├ Message msg_a   (user, "rewrite ToolGroupView")
      │   └ Message msg_b   (agent, streamed reply, finalises)
      │       ├ Task task_1 (read_file ToolGroupView.swift)
      │       └ Task task_2 (write_file ToolGroupView.swift)
      │           └ Approval apr_1 (severity=high, allow-once)
      └ Interaction int_b   ("now run the tests")
          └ …
```

A reasonable place to read next:

- [**Quickstart**](/quickstart/) — pair OpenClaw and watch this hierarchy materialise in iOS.
- [**Protocol overview**](/protocol/) — the wire-level perspective on the same model.
- [**Glossary**](/glossary/) — alphabetical lookup if you forget what one of these meant.


---
URL: https://docs.sophon.at/protocol/connection/

# 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&apos;t have
one yet. Pairing trades a short-lived 7-letter code for a long-lived
bridge token.

**Bridge → server**:

```http
POST /v1/pairing/start
Content-Type: application/json

{
  "connector_type": "openclaw",
  "host_label": "serafim's mac"
}
```

**Server → bridge**:

```jsonc
{
  "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):

```http
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:

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

Stash the token in the bridge&apos;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:

```http
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

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

On success the server sends:

```jsonc
{ "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:

```jsonc
{
  "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](/protocol/wire/) for shapes.

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

```jsonc
{ "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](/protocol/wire/) and
the [Tool calls & approvals](/protocol/tools-and-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&apos;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

```ts
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**](/protocol/streaming/) for the per-turn
event order, then [**Write your own connector**](/connectors/custom/)
for the full glue.


---
URL: https://docs.sophon.at/protocol/errors/

# Errors & rate limits

Every error from `/v1/*` follows the same envelope, every
non-trivial route emits the same rate-limit headers, and the rules
for a sane client are short.

## Error envelope

```jsonc
{
  "ok": false,
  "error": {
    "code": "validation_failed",   // snake_case machine code
    "message": "Human-readable",
    "errors": [                    // optional, only on 400 validation_failed
      {
        "path": "attachments.0.size",
        "code": "too_big",
        "message": "Number must be less than or equal to 26214400"
      }
    ],
    "details": { /* optional, code-specific structured payload */ },
    "retry_after_ms": 5000         // optional, set on 429 / 503
  }
}
```

The `errors[]` field-path detail mirrors the request body&apos;s
nesting — array indices are bare integers (`attachments.0.size`),
nested objects are dotted (`payload.user.email`). Each entry
carries:

- `path`: dot-joined field path (`""` for root-level type
  mismatches).
- `code`: machine code (zod issue codes pass through verbatim:
  `invalid_type`, `too_big`, `too_small`, `invalid_string`, …).
- `message`: human-readable.

A client can render exactly the field that failed without parsing
prose.

## Standard error codes

| HTTP | Code | Meaning |
|---|---|---|
| 400 | `invalid_request` | Malformed body, schema fail (carries `errors[]`) |
| 400 | `invalid_token_location` | Token in URL/query — must be header |
| 401 | `invalid_token` | Token unknown, malformed, revoked |
| 401 | `invalid_signature` | Webhook HMAC fail |
| 403 | `permission_denied` | Token lacks required scope |
| 403 | `installation_revoked` | Installation gone |
| 404 | `session_not_found` | Session deleted or never existed |
| 404 | `interaction_not_found` | Interaction expired or invalid |
| 409 | `idempotency_conflict` | Same key, different payload |
| 410 | `installation_revoked` | Mid-stream revoke |
| 410 | `session_deleted` | Mid-stream delete |
| 410 | `interaction_expired` | >30 min since update — followup window closed |
| 413 | `payload_too_large` | >1 MB JSON or >25 MB attachment |
| 422 | `tool_not_declared` | Tool name not in agent manifest |
| 429 | `rate_limited` | Per-token, per-installation, or per-IP limit |
| 451 | `content_blocked` | Backend content filter blocked output |
| 500 | `internal_error` | Backend bug |
| 502 | `upstream_error` | Bridge bus failure or similar |
| 503 | `temporarily_unavailable` | Backend overloaded — retry with `retry_after_ms` |
| 503 | `agent_degraded` | Agent in degraded state, snoozed |

The full list lives in the
[SAP RFC § 15.2](https://github.com/serafimcloud/s-chat/blob/main/docs/SAP_RFC.md#152-standard-error-codes).

## Error handling rules

- **4xx**: do not retry blindly. Inspect the code.
- **429 / 503**: retry after `retry_after_ms` with jitter.
- **Other 5xx**: retry up to 3× with exponential backoff.
- **410**: drop in-flight work and stop sending more — the thing
  you were writing to is gone.

## Rate limit headers

Every response carries Discord-compatible rate-limit headers (no
vendor prefix):

```
X-RateLimit-Limit: 30                   # capacity (max burst)
X-RateLimit-Remaining: 27               # tokens left after this request
X-RateLimit-Reset: 1730345700           # epoch seconds until full
X-RateLimit-Reset-After: 0.300          # seconds (decimal) until full
X-RateLimit-Bucket: msg                 # bucket id for client-side scheduling
X-RateLimit-Scope: installation         # 'installation' | 'agent'
```

On 429 the response additionally carries:

```
Retry-After: 1                          # integer seconds (RFC 9110 § 10.2.3)
```

## Buckets

| Bucket | Routes | Capacity | Refill/sec |
|---|---|---|---|
| `msg` | `sendMessage`, `sendMessageEnd` | 30 | 10 |
| `delta` | `sendMessageDelta` | 200 | 100 |
| `task` | `createTask`, `updateTask`, `finishTask` | 60 | 30 |
| `approval` | `requestApproval` | 10 | 2 |
| `memory` | `getMemory`, `setMemory` (deferred) | 60 | 20 |
| `default` | everything else | 30 | 10 |

Buckets are scoped per `(scope, owner_id, bucket)` — two
installations never share a bucket.

## How to be a well-behaved client

1. **Read the headers, don&apos;t hit 429.** Build a local
   token-bucket scheduler keyed on
   `X-RateLimit-Bucket`. Refill at the rate the table specifies.
   Treat `X-RateLimit-Remaining` as authoritative — if it&apos;s
   `0`, wait for `X-RateLimit-Reset-After` before sending the
   next request in that bucket.
2. **Coalesce deltas.** If your agent emits a token every 8 ms,
   batch them into ~30 ms windows before POSTing. The user
   can&apos;t see faster than that anyway, and the `delta` bucket
   refills at 100/s.
3. **Retry with idempotency.** Every retry must reuse the same
   `idempotency_key` (or `task_id` / `approval_id`). See
   [Idempotency & resume](/protocol/idempotency/).
4. **Backoff on 429 / 503.** Use `Retry-After` (or
   `retry_after_ms`) with ±25 % jitter. Never tight-loop.
5. **Stop on 410.** The session, interaction, or installation
   you were writing to is gone. Drop the in-flight work and tell
   the agent to wind down.
6. **Log `X-Request-ID` on every error.** It&apos;s the only
   thing that lets us correlate your bug report with our server
   logs. See [Observability](/protocol/observability/).


---
URL: https://docs.sophon.at/protocol/idempotency/

# Idempotency & resume

Bridges retry. Networks blip. Phones go to sleep mid-stream. SAP
is built so that all of these turn out to be no-ops on the client
side — every mutating route is keyed, the SSE stream has a ring
buffer, and there&apos;s a snapshot endpoint for everything older
than the buffer.

## Why bother

Without idempotency, every retry risks double-billing the user
(a duplicate bubble), corrupting the in-flight tool card (two
`task_completed` events for one task), or worse. With it, the
contract is simple: **the bridge POSTs until it gets a 2xx**, and
the server makes sure that&apos;s exactly-once on the user&apos;s
side.

## Per-route keys

Every mutating bridge route accepts an idempotency key. The
key&apos;s shape is per-route:

| Route | Key field | Notes |
|---|---|---|
| `sendMessage` | `idempotency_key` (UUID) | Unique on `(session_id, idempotency_key)` |
| `sendMessageDelta` | `idempotency_key` (UUID) | Unique on `(message_id, idempotency_key)` |
| `sendMessageEnd` | `idempotency_key` (UUID) | Unique on `(message_id, idempotency_key)` |
| `createTask` | `task_id` | The natural id is the key |
| `updateTask` | `task_id` (+ optional `idempotency_key` for distinct events) | |
| `finishTask` | `task_id` | The natural id is the key |
| `requestApproval` | `approval_id` | The natural id is the key |

For routes with an explicit `idempotency_key`:

- The pair `(token, idempotency_key)` is unique within 24 hours.
- Same key, **same** body → returns the cached response with
  `idempotent: true`.
- Same key, **different** body → `409 idempotency_conflict`.
- After 24 h, keys are recycled.

Use UUIDv4. If you want logs to be greppable, prefix with
something semantic: `int_abc-msg-attempt-1`.

## Retry pattern

```ts
async function postWithRetry(path: string, body: object) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const r = await fetch(`https://api.sophon.at${path}`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    })
    if (r.ok) return r.json()
    if (r.status === 429 || r.status === 503) {
      const retryAfter = Number(r.headers.get('Retry-After') ?? '1')
      await sleep((retryAfter + Math.random() * 0.25) * 1000)
      continue
    }
    if (r.status >= 500) {
      await sleep(2 ** attempt * 1000)
      continue
    }
    throw new Error(`POST ${path} → ${r.status}`)
  }
  throw new Error(`POST ${path} → exhausted retries`)
}
```

The body — including the `idempotency_key` — is identical every
attempt.

## At-least-once delivery

The server delivers updates **at-least-once** to your bridge.
Network drops between ack and processing happen. Your bridge
**must** dedupe by `update_id`:

```sql
-- Postgres
INSERT INTO processed_updates (update_id) VALUES ($1)
  ON CONFLICT DO NOTHING
  RETURNING update_id;
-- if no row returned, this is a duplicate, skip processing
```

Your `processed_updates` table can be tiny — keep the last 10 k
ids and TTL the older ones. The server&apos;s buffer is 5 minutes,
so anything older than that is ancient history.

## SSE resume on the iOS side

iOS connects to `GET /v1/me/stream` with `Last-Event-ID` set to
the highest event id it persisted. The server replays from there
through a **5-minute / 256-event ring buffer**, then continues
live.

```
GET /v1/me/stream HTTP/1.1
Authorization: Bearer <user_session>
Last-Event-ID: 12842

← id: 12843
  event: message_delta
  data: { ... }

← id: 12844
  event: message_finalized
  data: { ... }

← (continues live)
```

If the gap is larger than the buffer (the user went to sleep, the
TLS connection died on a flight), the server emits a special
event telling the client "you missed too much, fetch a snapshot".

## Cold-launch snapshot

```
GET /v1/me/snapshot
```

Sessions and messages are already durable — iOS hydrates them via
`/v1/me` and `/v1/me/sessions/:id/messages`. Tool calls are
ephemeral by design (W12 v1). What the SSE ring buffer DOES drop on
the floor when it rolls is the live state of pending approvals —
the user opens the app after lunch and the "may I run rm -rf?"
sheet should still be on screen.

So the snapshot endpoint returns just that:

```jsonc
{
  "ok": true,
  "result": {
    "ts": 1730345700000,                  // server time the snapshot was taken
    "pending_approvals": [
      {
        "approval_id":     "apr_…",
        "session_id":      "ses_…",
        "installation_id": "inst_…",
        "agent_id":        null,           // null on bridge approvals
        "interaction_id":  "int_…",
        "action":          "exec_command",
        "title":           "Run rm -rf …",
        "message":         "…",
        "severity":        "high",
        "command":         "rm -rf node_modules",
        "host":            "rom-MacBook-Pro",
        "tool_call_id":    "tc_…",
        "expires_at":      1730345900000,
        "ts":              1730345600000
      }
    ]
  }
}
```

iOS calls this on cold launch — `PlatformAdapter.refreshSnapshot()`
runs between `refreshMe()` and `stream.start()`. Each
`pending_approvals[]` entry folds through the same handler the SSE
`approval_requested` event uses, so re-emits dedupe by
`approval_id`. **Then** the SSE attaches and forward state is live.

Future waves may extend the response — there's room to add
`pending_tool_calls`, `unread_message_counts`, etc. without
breaking clients (the response is forward-compatible).

## Putting the rules together

Three invariants every connector author should hold in their
head:

1. **Mutating POST = retry until 2xx, with the same key.** The
   server makes it exactly-once.
2. **`update` from the WS = dedupe by `update_id`.** The server
   delivers at-least-once.
3. **Long-gap clients = call snapshot first, then SSE.** Don&apos;t
   try to walk older than 5 minutes through `Last-Event-ID`.

If you&apos;re building on top of OpenClaw, `connectors/openclaw-bridge`
already does (1) and the server handles (2) and (3) for the iOS
side — you don&apos;t need to think about them.


---
URL: https://docs.sophon.at/protocol/observability/

# Observability

Every response from `/v1/*` carries two correlation headers. If
you wire them through your logging and tracing, debugging a
streaming chat across three machines stops being a guessing game.

## Headers

```
X-Request-ID: req_3f2a7c8e1b4d5e6f
traceparent:  00-<trace_id>-<parent_id>-<flags>
tracestate:   rojo=00f067aa0ba902b7
```

Three things they let you do:

1. Correlate a server-side log line with a specific HTTP exchange
   the bridge made.
2. Stitch a distributed trace across iOS → cloud → bridge →
   agent.
3. Pass through opaque vendor state (Honeycomb, Sentry,
   Datadog…) without us inspecting it.

## `X-Request-ID`

Identifies one HTTP exchange. Format when server-generated:

```
req_<16 lowercase hex>      e.g. req_3f2a7c8e1b4d5e6f
```

Clients **may** supply their own value (`[A-Za-z0-9._:-]{1,64}`);
the server echoes it back unchanged.

The recommended pattern: every error your bridge logs should
include the `X-Request-ID` of the failing call. When you file a
bug or open a support ticket, paste it. The server has the same
id in its logs and can find your exact request in seconds.

```ts
const r = await fetch(url, { ... })
if (!r.ok) {
  const reqId = r.headers.get('X-Request-ID')
  log.error({ reqId, status: r.status }, 'sophon POST failed')
}
```

## `traceparent` (W3C Trace Context)

Format:

```
<version>-<trace_id>-<parent_id>-<flags>
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
```

- `version`: always `00` for v1.
- `trace_id`: 32 hex chars, identifies the distributed trace.
- `parent_id`: 16 hex chars, identifies *this server&apos;s* span
  inside the trace. Agents stitching their own spans below this
  use `parent_id` as their parent reference.
- `flags`: `01` = sampled, `00` = not sampled.

If your bridge sends a valid inbound `traceparent` (you already
have a span), the server keeps your `trace_id` and mints a new
`parent_id` (the server is now a child of your inbound caller).
If you don&apos;t send one, the server generates a fresh trace
with `flags=01`.

## `tracestate`

Comma-delimited list of opaque vendor state, per the W3C spec.
The server passes it through verbatim — never inspects, never
rewrites. If you&apos;re using Honeycomb / Datadog / Lightstep / a
homegrown tracer, this is where their per-vendor context rides
along.

## OpenTelemetry pass-through

If your bridge already runs an OTel SDK, you get distributed
traces for free. The HTTP client should:

- **Inject** `traceparent` / `tracestate` outbound on every
  Sophon request.
- **Extract** the returned `traceparent` and attach it as a span
  link on the outgoing request span.

Most OTel HTTP instrumentations do both automatically. With Pino,
Sentry, or a homegrown logger, just thread the
`X-Request-ID` and `traceparent` into your structured log record
and you&apos;re done.

## Server-side logging hooks

Server logs include the same `request_id` and `trace_id` on
every line that handled the request. When you ping the team with
a bug:

> Bridge call to `/v1/bridge/createTask` returned 500.
> `X-Request-ID: req_3f2a7c8e1b4d5e6f`,
> `traceparent: 00-4bf9…-00f0…-01`.

…we have everything we need to find the line and the upstream
context.

## What we don&apos;t do

- We do **not** sniff your `tracestate`.
- We do **not** persist `X-Request-ID` beyond the standard log
  retention window.
- We do **not** require `traceparent` to be present — you&apos;re
  perfectly fine sending none, you just lose the cross-system
  view.


---
URL: https://docs.sophon.at/protocol/

# Protocol overview

The Sophon Agent Protocol (SAP) is the wire format the iOS app and
every bridge speak. It&apos;s HTTPS + Server-Sent Events on the iOS
side, HTTPS + WebSocket on the bridge side, and JSON all the way
down. No proprietary SDK is required.

## Three layers

```
┌──────────────────────┐
│  iOS  ↔  Cloud       │   /v1/me/*       SSE on /v1/me/stream
├──────────────────────┤
│  Cloud ↔  Bridge     │   /v1/bridge/*   WS on /v1/bridge/ws
├──────────────────────┤
│  Bridge ↔  Agent     │   anything you like — stdio, HTTP, WS
└──────────────────────┘
```

Each layer is independent:

- **iOS ↔ Cloud** is what your phone uses to send messages, fetch
  history, archive sessions, manage installations. The iOS app is
  the only consumer.
- **Cloud ↔ Bridge** is what this protocol specifies for connector
  authors. Your bridge connects here, receives `session.message`
  events, and POSTs back replies, tool events, approval requests.
- **Bridge ↔ Agent** is whatever your agent already speaks.
  OpenClaw uses a local WebSocket. Your custom bridge can shell out
  to a CLI, call an LLM, drive an MCP server — your call.

## What SAP isn&apos;t

It&apos;s worth being explicit:

- **Not a marketplace.** Sophon does not host a directory of agents
  for users to install. The iOS app is for **your** agents — the
  ones you ran the bridge for. (A shared-agent path exists in the
  protocol RFC under `/v1/bot/*` with `agt_*` tokens, but it&apos;s
  deferred indefinitely; nothing in shipping iOS surfaces it.)
- **Not a webhook directory.** The bridge holds a WebSocket open;
  the cloud doesn&apos;t POST to a public URL on your machine.
- **Not a hosting platform.** Your agent runs wherever you run it.
  We don&apos;t ship code, store secrets, or proxy LLM credentials.

## The supported token type

```
inst_<id>:s_<env>_<secret>
```

A bridge token. Issued at pairing, scoped to one installation,
stored in the bridge&apos;s keychain. Every `/v1/bridge/*` call
authenticates with `Authorization: Bearer inst_…:s_live_…`.

## Transports at a glance

| Channel | Direction | Endpoint |
|---|---|---|
| **REST** | bridge → server | `POST /v1/bridge/sendMessage`, etc. |
| **WebSocket** | server → bridge | `wss://api.sophon.at/v1/bridge/ws` |
| **REST** | iOS → server | `POST /v1/me/sessions/:id/send`, etc. |
| **SSE** | server → iOS | `GET /v1/me/stream` |

REST POSTs are idempotent on `idempotency_key`; the WS uses
ping/pong heartbeats every 30 s; the SSE stream resumes via
`Last-Event-ID` against a 5-minute / 256-event ring buffer.

## Event taxonomy

Every event ultimately reaches iOS via SSE. The names are stable
across the surface:

```
hello                       — connection ack
heartbeat                   — keep-alive every 25 s
message_added               — a new message landed (user or agent)
message_delta               — streaming chunk for an existing message
message_finalized           — final canonical text + usage metadata
session_created             — chat session opened
session_renamed             — title changed
session_updated             — state changed (active ↔ archived)
session_deleted             — soft-deleted (24 h grace)
installation_created        — new bridge paired
installation_revoked        — installation gone
installation_updated        — display name / emoji changed
agent_health_changed        — degraded / healthy
task_created                — tool call started
task_progress               — partial result
task_completed | task_failed | task_cancelled
approval_requested          — HITL: agent wants user permission
approval_resolved           — user decided
device_capability_request   — agent asks the phone for camera / GPS
```

Same wire shapes, regardless of which connector emitted them.

## Snake_case wire

Every JSON body — REST, SSE data, WS frame — uses `snake_case`
keys (`session_id`, `interaction_id`, `created_at`). The server
projects every database row through `lib/wire.ts` so the iOS-side
typed decoders see what they expect.

## Read on

- [**Connection lifecycle**](/protocol/connection/) — pair, dial,
  receive, reconnect.
- [**Streaming model**](/protocol/streaming/) — how text deltas,
  tool events, and approvals interleave inside one interaction.
- [**Tool calls & approvals**](/protocol/tools-and-approvals/) —
  the `task_*` and `approval_*` event sequences end-to-end.
- [**Wire reference**](/protocol/wire/) — every payload field,
  copy-paste ready.
- [**Errors & rate limits**](/protocol/errors/) — envelope shape,
  status codes, rate-limit headers.
- [**Idempotency & resume**](/protocol/idempotency/) — why and how
  to retry safely.
- [**Observability**](/protocol/observability/) — request IDs,
  W3C trace context, OpenTelemetry pass-through.

The exhaustive normative spec lives in the
[SAP RFC](https://github.com/serafimcloud/s-chat/blob/main/docs/SAP_RFC.md).
The pages above are the human-readable derivative; the RFC is the
source of truth.


---
URL: https://docs.sophon.at/protocol/streaming/

# Streaming model

A single agent turn is **one interaction**, identified by an
`interaction_id`. Every event SAP fires during that turn carries
the same id, so the iOS client can route deltas to the right
bubble.

## Event order in a normal text-only turn

```
1. POST /v1/me/sessions/:id/send         → returns { interaction_id, message_id }
2. message_added (role=user, interaction_id, …)        ← server echo
3. message_added (role=agent, text=" ", interaction_id) ← placeholder bubble
4. message_delta × N (interaction_id, delta="…")
5. message_finalized (interaction_id, text=full, usage)
```

Bullet 3 is the **placeholder bubble** — your bridge POSTs it via
`/v1/bridge/sendMessage` so iOS can show "Thinking…" with the
right bubble shape immediately. Without it the user stares at a
blank chat for the whole LLM round-trip.

## Tool calls inside a turn

Tool invocations slot **between** bullets 4 and 5 (or alongside
deltas, if the model streams while the tool runs):

```
…
4a. task_created  (task_id, kind=exec, status_label="ls -la")
4b. task_progress (task_id, partial_result?)
4c. task_completed | task_failed | task_cancelled
5.  message_finalized
```

iOS coalesces consecutive `task_*` events for the same interaction
into a single ToolGroupView ("Ran 3 commands") collapsible row.
Tap to drill into per-tool args and result. The same data also
surfaces inline as `AssistantSegment.toolCall` /
`AssistantSegment.toolResult` on the agent bubble, so a user
opening the chat next month can still see what happened.

## Approvals (HITL)

If the agent needs user permission mid-turn it pauses and emits
an `approval_requested`:

```
4a. task_created            (task_id, kind=exec)
4b. approval_requested      (approval_id, command, severity, …)
    ↓ user taps Allow / Allow always / Deny in iOS
4c. POST /v1/me/approvals/:id  { decision }
4d. approval_resolved       (approval_id, decision)
4e. task_completed | task_failed
```

The bridge listens for `approval.resolved` on the bus and unblocks
the agent. See [Tool calls & approvals](/protocol/tools-and-approvals/)
for the full HITL contract.

## Resume on reconnect

The server keeps a **5-minute / 256-event ring buffer** per user.
When the iOS app reconnects, it sends `Last-Event-ID` automatically;
the server replays everything past that id, then continues live.

For gaps older than 5 minutes — the user backgrounded the app for
half an hour, the LTE connection died on a train — sessions and
messages are already durable (`/v1/me` + `/v1/me/sessions/:id/messages`
on cold launch). What the ring buffer drops is **live ephemeral
state**: the "may I run this command?" approval that fired while
the app was killed. The **cold-launch snapshot endpoint** plugs
that gap:

```
GET /v1/me/snapshot
→ { ts, pending_approvals: [...] }
```

iOS calls this in `refreshSnapshot()` between `refreshMe()` and the
SSE attach. Each entry folds through the same handler the live
`approval_requested` event uses, so re-emits dedupe by
`approval_id`. See the [Idempotency & resume](/protocol/idempotency/)
page for the exact response shape.

## Pending-run resume on the iOS side

When you kill the app mid-stream, iOS persists `(session_id,
bubble_id, run_id)` to disk. On cold launch `tryResumePendingRuns()`
walks each record:

1. **Run id present** — `adapter.resumeRun(sessionId, runId)`
   opens a fresh continuation. A watchdog polls
   `GET /v1/me/sessions/:id/messages` every 3 s for up to 24 s; if
   the agent reply with that `interaction_id` is already in the DB
   we resolve as if SSE delivered.
2. **Run id absent** — user killed the app inside the
   `/sessions/:id/send` round-trip. We just reload history; the
   server-stored turn surfaces on the next `loadHistory()`.

Either way the user reopens the chat and sees what the agent was
doing, even if the SSE stream missed every event in the gap.

## Backpressure

Bridges should **serialise** their writes back to Sophon, so a
fast burst of deltas doesn&apos;t race the placeholder-bubble
create. A simple in-flight queue per `(session_id, interaction_id)`
is enough — the OpenClaw bridge in
`connectors/openclaw-bridge/src/sophon.ts` does exactly this.

If you push deltas faster than the rate-limit bucket allows
(`delta` bucket: 200 capacity, 100/s refill), you&apos;ll start
getting `429 rate_limited` with `Retry-After`. See
[Errors & rate limits](/protocol/errors/) for the bucket table.


---
URL: https://docs.sophon.at/protocol/tools-and-approvals/

# Tool calls & approvals

Every tool run on the bridge fans out as **two parallel streams**:

1. The live activity deck — `task_*` SSE events feed iOS'
   `tasksBySession` map, which `ToolGroupView` renders as "Running 1
   command…" above the chat.
2. The persistent record — the same tool calls land as
   `AssistantSegment.toolCall` / `.toolResult` segments inside the
   agent bubble, so a user opening the chat next month can still
   drill into the args and the output.

## Bridge → server routes

```
POST /v1/bridge/createTask
  { session_id, interaction_id, task_id, kind, status_label?, args? }

POST /v1/bridge/updateTask
  { session_id, interaction_id, task_id, progress_percent?, partial_result? }

POST /v1/bridge/finishTask
  { session_id, interaction_id, task_id, name?, status, error?, result? }
  status: completed | failed | cancelled
```

`task_id` is the underlying provider tool-call id (e.g. Anthropic's
`call_…|fc_…`). Up to 256 chars. Everything else is optional metadata
the renderer uses for the collapsed label.

## Server → user fan-out

```
publish(user_id, { type: 'task_created',   data: { task_id, session_id,
                                                   kind, status_label,
                                                   interaction_id, args } })

publish(user_id, { type: 'task_progress',  data: { task_id, progress_percent,
                                                   status_label } })

publish(user_id, { type: 'task_completed', data: { task_id, name?, status_label?,
                                                   result?, interaction_id } })
```

iOS routes every task_* event by `(session_id, interaction_id)` to:
- mutate `tasksBySession[session_id]` (live deck), AND
- yield `.toolStart(ToolCall)` / `.toolEnd(ToolResult)` to the
  in-flight `send()` continuation, which `SessionStore`'s accumulator
  folds into `ChatMessage.segments` for persistence.

## HITL approvals

Approvals pause the run until the user decides. The wire shape
mirrors `task_*` but on a separate channel:

```
Bridge → Server:
  POST /v1/bridge/requestApproval
    { session_id, interaction_id, approval_id, action, title,
      command?, host?, message, severity, idempotency_key }

Server → User SSE:
  approval_requested data:
    { approval_id, installation_id, agent_id, session_id,
      action, severity, title, message, command?, host?,
      tool_call_id?, expires_at }

User → Server:
  POST /v1/me/approvals/:approval_id
    { decision: 'approve' | 'approve_always' | 'deny',
      scope?:    'session' | 'tool' | 'domain' | 'all',
      scope_value? }

Server → User SSE:    approval_resolved { approval_id, decision }
Server → Bridge bus:  approval.resolved   (same payload)

Bridge → OpenClaw:
  exec.approval.resolve / plugin.approval.resolve
    { id, decision: 'allow-once' | 'allow-always' | 'deny' }
```

The iOS decisions map onto OpenClaw's vocabulary 1-to-1:

| iOS decision     | Server stores | OpenClaw RPC param |
|------------------|---------------|--------------------|
| approve          | approve       | allow-once         |
| approve_always   | approve_always | allow-always      |
| deny             | deny          | deny               |
| approveForSession (UI only) | approve | allow-once |
| approveCommandAlways (UI only) | approve_always (scope: tool) | allow-always |

A successful `approve_always` writes a permanent grant onto
`installations.metadata.approval_grants`; the next time the agent
asks for that (`action`, `scope`, `scope_value`) tuple the server
auto-resolves without bothering the user.

## Idempotency

Every bridge task route — `createTask`, `updateTask`, `finishTask`,
`requestApproval` — is idempotent. The keys are the natural ids:

- `createTask`, `updateTask`, `finishTask` upsert on `task_id`.
  Re-sending the same shape returns `idempotent: true`.
- `requestApproval` upserts on `approval_id`.

Useful when the bridge retries after a flaky network. See
[Idempotency & resume](/protocol/idempotency/) for the wider rules.

## Expiry

Approvals carry a server-side `expires_at` (default 5 min). The
expiry sweeper publishes `approval.expired` to the bridge bus when
the user doesn't decide in time; the bridge tells OpenClaw to deny.

## See also

- [Write your own connector](/connectors/custom/) — wiring
  `requestApproval` through a custom backend.
- [Errors & rate limits](/protocol/errors/) — what to do when
  `approval` or `task` routes return 429 / 5xx.


---
URL: https://docs.sophon.at/protocol/wire/

# Wire reference

The compact, payload-by-payload spec. All keys snake_case. Every
timestamp is **ms since epoch** unless noted.

## Envelope

Every JSON response uses the same envelope:

```json
{
  "ok": true,
  "result": { /* per-route */ }
}
```

On error:

```json
{
  "ok": false,
  "error": {
    "code": "session_not_found",
    "message": "optional human copy",
    "errors": [
      {
        "path": "attachments.0.size",
        "code": "too_big",
        "message": "Number must be less than or equal to 26214400"
      }
    ],
    "retry_after_ms": 1500
  }
}
```

`errors[]` is present on validation failures (`400 invalid_request`)
and absent or empty otherwise. See [Errors & rate limits](/protocol/errors/)
for the full code table and rate-limit headers.

## REST — bridge namespace

`/v1/bridge/sendMessage`

```json
{
  "session_id": "ses_…",
  "interaction_id": "int_…?",
  "text": "agent reply text",
  "attachments": [{ "key": "u/…", "mime": "image/png", "size": 1024, "name": null }],
  "reply_to": "msg_…?",
  "idempotency_key": "uuid",
  "usage": {
    "input_tokens": 12,
    "output_tokens": 34,
    "estimated_cost_usd": 0.0012,
    "model": "claude-sonnet-4-5",
    "provider": "anthropic"
  }
}
```

`/v1/bridge/sendMessageDelta`

```json
{ "message_id": "msg_…", "delta": "next chunk", "idempotency_key": "uuid" }
```

`/v1/bridge/sendMessageEnd`

```json
{
  "message_id": "msg_…",
  "text": "final canonical text (optional — server keeps the streamed text otherwise)",
  "usage": { "...": "..." },
  "finish_reason": "stop | length | content_filter | tool_call",
  "idempotency_key": "uuid"
}
```

`/v1/bridge/createTask` / `updateTask` / `finishTask` — see
[Tool calls & approvals](/protocol/tools-and-approvals/).

`/v1/bridge/requestApproval` — same.

## REST — user namespace

`POST /v1/me/sessions/:id/send`

```json
{
  "text": "user message",
  "attachments": [{ "key": "u/…", "mime": "image/png", "size": 1024, "name": null }],
  "reply_to": "msg_…?",
  "thought_level": "default | extended | max"
}
```

`POST /v1/me/sessions/:id/archive` / `unarchive` — empty body.

`PATCH /v1/me/sessions/:id` — `{ title: string | null }`.

`DELETE /v1/me/sessions/:id` — soft-delete with 24 h grace.

`PATCH /v1/me/installations/:id`

```json
{
  "custom_display_name": "Work Mac" | null,
  "custom_emoji": "🦞" | null
}
```

`POST /v1/me/approvals/:id`

```json
{ "decision": "approve | deny | approve_always", "scope": "tool", "scope_value": "Bash" }
```

## SSE — `/v1/me/stream`

Each event line follows the standard EventSource format:

```
id: 42
event: message_delta
data: {"session_id":"ses_…","message_id":"msg_…","delta":"hello","interaction_id":"int_…","ts":1730000000000}
```

`Last-Event-ID` on reconnect replays the 5-minute / 256-event ring
buffer. iOS sends it automatically.

## WebSocket — `/v1/bridge/ws`

```
server → bridge:
  { "type": "ready", "installation_id": "inst_…" }
  { "type": "update", "update": { "update_id": "1", "type": "session.message", "payload": {...}, "session_id": "ses_…", "interaction_id": "int_…", "installation_id": "inst_…", "created_at": "iso" } }
  { "type": "ping" }

bridge → server:
  { "type": "ack", "up_to_update_id": "12" }
  { "type": "pong" }
```

Authentication: `Authorization: Bearer inst_…:s_…` header during
upgrade.


---
URL: https://docs.sophon.at/connectors/custom/

# 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&apos;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:

```http
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:

```http
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:

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

Stash the token. Don&apos;t commit it to a repo. From this point
on, you only need the token.

## Step 2 — open the WebSocket

```ts
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:

```jsonc
{ "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`

```ts
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&apos;ll see eventually: `approval.resolved`
(after iOS sends back a decision), `session.cancelled` (user
cancelled the turn), `installation.permissions_updated`. See the
[Wire reference](/protocol/wire/).

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

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```jsonc
{
  "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:

```ts
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&apos;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&apos;t render the affordance —
it just falls through. Nothing breaks; the user just doesn&apos;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&apos;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`](https://github.com/serafimcloud/s-chat/tree/main/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**](/protocol/connection/) — pairing,
  reconnect, heartbeats — the parts the example glosses over.
- [**Idempotency & resume**](/protocol/idempotency/) — how to
  retry safely.
- [**Errors & rate limits**](/protocol/errors/) — be a
  well-behaved client.
- [**Wire reference**](/protocol/wire/) — every payload field.


---
URL: https://docs.sophon.at/connectors/openclaw/

# OpenClaw bridge

`@sophonai/openclaw` is the canonical Sophon connector. It wraps
a local **OpenClaw** gateway as a paired Sophon installation. Once
paired, you chat with your OpenClaw from the iPhone exactly as if
it were a native Sophon agent — same rendering, same streaming,
same tool-call cards, same HITL approvals.

If you&apos;re here because you want a Claude-Code-shaped agent on
your phone with five minutes of setup, this is the page.

## What is OpenClaw?

OpenClaw is the open-source, self-hosted gateway that powers
Claude Code-style local agents. It exposes a JSON-RPC WebSocket
on `localhost:18789` with a chat surface, tool execution, and
human-in-the-loop approvals. The Sophon bridge is a thin
translator between OpenClaw&apos;s wire format and SAP — nothing
more.

## Install and pair

```sh
npx @sophonai/openclaw
```

The CLI:

1. Connects to OpenClaw on `ws://localhost:18789` (override with
   `--openclaw-url`).
2. Asks Sophon Cloud for a 7-letter pairing code via
   `/v1/pairing/start`.
3. Polls until the user types the code into iOS.
4. On success, holds two long-lived connections — one to the
   gateway and one to `wss://api.sophon.at/v1/bridge/ws` — and
   forwards events both directions.

Stop the process at any time; iOS marks the installation as
*degraded* until it comes back. Re-running the CLI with the
stashed token reconnects without re-pairing.

## What it forwards

The bridge translates OpenClaw events into SAP events one-for-one:

| OpenClaw → bridge | Bridge → SAP | iOS sees |
|---|---|---|
| `agent` event, `stream:'assistant'` | `POST /v1/bridge/sendMessageDelta` | `message_delta` SSE |
| `agent` event, `stream:'lifecycle'` end | `POST /v1/bridge/sendMessageEnd` | `message_finalized` SSE |
| `agent` event, `stream:'item'`, kind=tool/command | `POST /v1/bridge/createTask | updateTask | finishTask` | `task_*` SSE |
| `agent` event, `stream:'approval'`, phase=requested | `POST /v1/bridge/requestApproval` | `approval_requested` SSE |
| — (server publishes from `/v1/me/approvals/:id`) | server → bridge bus | `approval_resolved` SSE |

Reverse direction (user → agent) flows through the bridge bus:

| iOS → SAP | SAP → bridge (bus) | Bridge → OpenClaw |
|---|---|---|
| `POST /v1/me/sessions/:id/send` | `session.message` | `chat.send` RPC |
| `POST /v1/me/approvals/:id` | `approval.resolved` | `exec.approval.resolve` / `plugin.approval.resolve` |
| `DELETE /v1/me/sessions/:id` | `session.cancelled` | `chat.abort` (in progress) |

## Operator scopes

The bridge claims three operator scopes at handshake:

```js
scopes: ['operator.read', 'operator.write', 'operator.approvals']
```

`operator.approvals` is needed for the `*.approval.resolve` RPCs.
If you self-host an OpenClaw with a stricter scope policy, add
this to its allowlist.

## Tool-frame deduplication

OpenClaw fans some tool lifecycles through **two** `stream:'item'`
channels — one with `kind:'tool'` (provider-level) and one with
`kind:'command'` (exec wrapper). The bridge dedups by
`(runId, toolCallId, phase)` so iOS doesn&apos;t render *"Ran 2
commands"* when the agent ran one. The iOS adapter does a
defensive id-check too.

## Approval mapping

iOS&apos;s rich approval vocabulary collapses onto OpenClaw&apos;s
three decisions:

| iOS decision | Server stores | OpenClaw RPC |
|---|---|---|
| `approve` | `approve` | `allow-once` |
| `approveForSession` | `approve` | `allow-once` |
| `approve_always` (scope=tool) | `approve_always` | `allow-always` |
| `deny`, `abort` | `deny` | `deny` |

`approve_always` writes a permanent grant onto
`installations.metadata.approval_grants`. The next time the agent
asks for the same `(action, scope, scope_value)` tuple, the
server auto-resolves without bothering you.

## Multi-host install

Run `@sophonai/openclaw` on a second Mac with the same Sophon
account. iOS sees it as a second installation under the same
connector type — distinguish them via the **emoji** + **custom
name** swipe-action in Settings.

## CLI flags

```
--openclaw-url <ws-url>            (default ws://localhost:18789)
--openclaw-token <token>           OpenClaw operator token
--sophon-base <url>                (default https://api.sophon.at)
--sophon-token <inst_…:s_…>        skip pairing (CI / re-attach)
--host-label <name>                visible in Sophon's host list
--verbose
```

## Where to find the code

The OpenClaw bridge lives in this repo at
[`s-chat-cloud/connectors/openclaw-bridge/`](https://github.com/serafimcloud/s-chat/tree/main/s-chat-cloud/connectors/openclaw-bridge).
~600 lines of TypeScript across five files:

| File | What it does |
|---|---|
| `src/cli.ts` | Argument parsing, lifecycle |
| `src/pair.ts` | Pairing handshake (`/v1/pairing/start` + poll) |
| `src/sophon.ts` | SAP client (WS + REST) with idempotency + backpressure |
| `src/openclaw.ts` | OpenClaw RPC client and event normaliser |
| `src/bridge.ts` | The glue — translate event streams both ways |

If you&apos;re writing your own connector, this is the canonical
reference implementation. See
[Write your own connector](/connectors/custom/) for the path
when your agent doesn&apos;t speak OpenClaw.

## Troubleshooting

- **401 on `/v1/bridge/ws`** — your install token was revoked or
  rotated. Re-pair.
- **`exec.approval.resolve INVALID_REQUEST`** — your gateway
  version uses a different param name; update OpenClaw to ≥ v3.
- **Tool cards show twice** — older bridge dist; rebuild and
  restart.
- **Pairing code times out** — codes expire after 120 s. Restart
  the CLI to mint a fresh one.


---
URL: https://docs.sophon.at/glossary/

# Glossary

Alphabetical. Cross-references the [Concepts](/concepts/) page,
which goes in slightly more depth.

## Agent

The actual program doing the work — a Claude wrapper, a Python
script, a shell pipeline, your own LLM call. Lives on **your**
machine. Sophon never sees it; we only see what your bridge
forwards.

## Approval

Human-in-the-loop pause. The agent pauses and emits an
`approval_requested` event; iOS surfaces an action sheet; the
user taps Allow / Allow always / Deny; the decision flows back
through the bridge bus as `approval.resolved`. See
[Tool calls & approvals](/protocol/tools-and-approvals/).

## Bridge

A long-lived process that translates between your agent and SAP.
Holds a WebSocket open to `wss://api.sophon.at/v1/bridge/ws`,
listens for `session.message` updates, POSTs replies through
`/v1/bridge/*`. **OpenClaw** is the canonical example.
Synonymous with **connector** in everyday usage.

## Bridge token

A long-lived secret of the form `inst_<id>:s_<env>_<secret>`,
issued at pairing. Authenticates every `/v1/bridge/*` call.
Stored on the bridge side; lost on revoke.

## Bucket

Rate-limit grouping. Routes are bucketed (`msg`, `delta`, `task`,
`approval`, `memory`, `default`); each bucket is scoped per
`(scope, owner_id, bucket)`. Returned in the `X-RateLimit-Bucket`
header. See [Errors & rate limits](/protocol/errors/).

## Connector

Synonym for **bridge** in casual use. Strictly speaking, the
"connector" is the runtime + the SAP-translation layer; the
"bridge" is the relay process. Most days the distinction
doesn&apos;t matter.

## Connector type

Identifier for the kind of bridge — e.g. `openclaw`. Sent at
pairing time; iOS uses it to pick the right install instructions.

## Idempotency key

Opaque string, 1–64 chars `[a-zA-Z0-9_-]`, attached to every
mutating request. The pair `(token, idempotency_key)` is unique
within 24 h. Lets your bridge retry safely. See
[Idempotency & resume](/protocol/idempotency/).

## Installation

One paired bridge on your account. Identified by an
`installation_id` like `inst_q9w8e7r6t5y4u3i2`. Each pairing
creates one installation; you can have many under the same
account (work Mac, home Mac, a VPS).

## Interaction

One user-message-then-agent-reply cycle inside a session.
Identified by `interaction_id` (`int_…`). Every event during the
cycle (deltas, tool calls, approvals) carries this id so the
client can route deltas to the right bubble.

## Message

What you see in a bubble. Has a `message_id`, role
(`user` / `agent`), text, optional attachments, optional usage
metadata. Streams as deltas during an interaction and finalises
on `message_end`.

## Pairing

The handshake that issues a bridge token. The bridge calls
`POST /v1/pairing/start`, prints a 7-letter code, polls; the user
types the code in iOS Settings; the bridge&apos;s next poll
returns the token. See [Connection lifecycle](/protocol/connection/).

## Pairing code

A 7-letter string (e.g. `9FP9SVT`) valid for 120 s. Bridge prints
it; user types it in iOS to claim.

## Resume

Reconnecting to a stream without losing data. Two flavours:

- **SSE resume** — iOS sends `Last-Event-ID`; server replays the
  ring buffer.
- **Cold-launch snapshot** — for gaps older than the buffer, iOS
  calls `GET /v1/me/snapshot?since=<ts>` and folds the result
  into local state.

## Ring buffer

Server-side per-user buffer of the last 256 SSE events / 5
minutes, used for `Last-Event-ID` resume. Gaps older than this
require a snapshot fetch.

## SAP

Sophon Agent Protocol. The wire format documented on this site;
[normative spec here](https://github.com/serafimcloud/s-chat/blob/main/docs/SAP_RFC.md).

## Session

A chat thread inside one installation. Has a `session_id`
(`ses_…`), a title, a state (`active`, `archived`, soft-deleted).
Visible in the iOS chat list.

## Session token

The user&apos;s authentication on `/v1/me/*` calls. Owned by
iOS, never seen by your bridge.

## Snapshot

`GET /v1/me/snapshot?since=<ms>`. Returns every session, message,
task, and approval that changed since the timestamp. Used by iOS
on cold launch when the gap exceeds the SSE ring buffer. See
[Streaming model](/protocol/streaming/).

## SSE

Server-Sent Events. The transport iOS uses to receive updates
from the cloud — `GET /v1/me/stream`.

## Task

Internal name for a tool call. The lifecycle is
`created → progress* → completed | failed | cancelled`. Has a
`task_id`. Renders as a card in the agent bubble. See
[Tool calls & approvals](/protocol/tools-and-approvals/).

## Tool call

A single tool invocation by the agent. Same thing as a **task**;
"tool call" is the user-facing word, "task" is the wire-format
word.

## traceparent

W3C Trace Context header. `<version>-<trace_id>-<parent_id>-<flags>`.
Returned on every `/v1/*` response. See
[Observability](/protocol/observability/).

## Update

A frame the server pushes to your bridge over WebSocket. Has an
`update_id` (monotonic per-installation) and a `type`
(`session.message`, `approval.resolved`, …). Dedupe by
`update_id` — at-least-once delivery.

## update_id

Monotonic per-installation integer, used for deduping. Send
`{ "type": "ack", "up_to_update_id": "12" }` to confirm receipt.

## WebSocket

The transport between your bridge and the cloud — `wss://api.sophon.at/v1/bridge/ws`.
Auth via `Authorization: Bearer inst_…` header on the upgrade.

## X-Request-ID

Per-HTTP-exchange correlation header. Format
`req_<16 lowercase hex>` when server-generated; clients may
supply their own. Log it on every error. See
[Observability](/protocol/observability/).


---
URL: https://docs.sophon.at/changelog/

# Changelog

## v0.1 — W12 wrap-up (May 2026)

The bridge data path is feature-complete for a
chat-with-tools-and-approvals workflow. Everything below is wired
through `/v1/bridge/*` and ships in iOS Phase 3.

### Added

- `/v1/bridge/createTask | updateTask | finishTask` — mirror the
  existing `task_*` SAP events for paired bridges. Surfaces tool
  calls inside the bubble (drill-in) and on the live activity deck.
- `/v1/bridge/requestApproval` — HITL for shell commands and
  plugin invocations. Resolution flows back through the bridge bus
  so the bridge can call `exec.approval.resolve` on OpenClaw.
- `/v1/me/sessions/:id/archive` + `/unarchive` — chat archive
  affordance with cross-device SSE sync.
- `/v1/me/installations/:id` PATCH now accepts `custom_emoji`
  alongside `custom_display_name`.
- `installation_updated` SSE event for cross-device rename + emoji
  sync.
- **Phase 3.5** — bridge task routes (`createTask`, `updateTask`,
  `finishTask`, `requestApproval`) are idempotent on their natural
  ids. Re-sending the same shape returns `idempotent: true`. See
  [Idempotency & resume](/protocol/idempotency/).
- **Phase 3.6** — structured validation errors. `400 invalid_request`
  responses now carry an `errors[]` array with field paths
  (`attachments.0.size`), zod issue codes (`too_big`,
  `invalid_type`), and human messages. See
  [Errors & rate limits](/protocol/errors/).
- **Phase 3.6** — `X-RateLimit-*` headers on every bridge response,
  bucketed (`msg`, `delta`, `task`, `approval`, `memory`, `default`)
  with documented capacities and refill rates.
- **Phase 3.6** — `X-Request-ID` and `traceparent` (W3C Trace
  Context) on every `/v1/*` response. See
  [Observability](/protocol/observability/).
- **Phase 3.7** — `GET /v1/me/snapshot` cold-launch endpoint.
  Returns `{ ts, pending_approvals }`. iOS folds it before the SSE
  attaches so a "may I run this?" sheet that fired while the app
  was killed reappears on relaunch.
- iOS: streaming-state granularity (`.thinking → .streaming` on
  first delta).
- iOS: file & image attachments forwarded through the bridge to
  OpenClaw&apos;s media pipeline.
- iOS: persistent tool segments inside the assistant bubble (the
  `task_*` events now also yield to the in-flight `send()`
  continuation, which `SessionStore`&apos;s accumulator folds into
  `ChatMessage.segments`).

### Fixed

- Snake_case sweep across `/v1/me/*` (`lib/wire.ts` projector).
  Previously `PlatformSession.lastActivityAt` was silently dropping
  to `now()` because the server emitted `lastActivityAt` and iOS
  expected `last_activity_at`.
- `GET /v1/me/sessions/:id/messages` returns wire-shape rows now;
  every chat history fetch was a hard-fail decode prior to this.
- Schema migration 0003 — `chat_sessions.agent_id` nullable for
  bridge-owned sessions.
- Schema migration 0004 — `approvals.agent_id` nullable for bridge
  approvals.
- Schema migration 0005 — `installations.custom_emoji` text column.

### Deferred

- Question prompts (`AskUserQuestion` tool surface).
- Server-side full-text search.
- Per-session model picker.
- Device-capability passthrough for bridge agents.
- Shared-agent path (`/v1/bot/*`, `agt_*` tokens). The protocol
  RFC defines it; iOS does not surface it. Personal bridges
  (`inst_*`) are the supported path indefinitely.

## v0.0 — W11 (April 2026)

- First end-to-end pairing flow.
- `/v1/bridge/sendMessage | sendMessageDelta | sendMessageEnd`.
- Bridge connector dist (`@sophonai/openclaw`) shipped to npm.
- iOS PlatformAdapter consumes the bridge namespace.
