Sophon

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 decisionServer storesOpenClaw RPC param
approveapproveallow-once
approve_alwaysapprove_alwaysallow-always
denydenydeny
approveForSession (UI only)approveallow-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 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