Tool calls & approvals
Every tool run on the bridge fans out as two parallel streams:
- The live activity deck —
task_*SSE events feed iOS'tasksBySessionmap, whichToolGroupViewrenders as "Running 1 command…" above the chat. - The persistent record — the same tool calls land as
AssistantSegment.toolCall/.toolResultsegments 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-flightsend()continuation, whichSessionStore's accumulator folds intoChatMessage.segmentsfor 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,finishTaskupsert ontask_id. Re-sending the same shape returnsidempotent: true.requestApprovalupserts onapproval_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
- Write your own connector — wiring
requestApprovalthrough a custom backend. - Errors & rate limits — what to do when
approvalortaskroutes return 429 / 5xx.