HQ Chat WebSocket
Wire reference for the HQ chat WebSocket at /ws/chat: the hello handshake, conversation subscriptions, Steer and Follow Up, question cards, voice mode, and browser live view.
Every chat client talks to an HQ through one WebSocket path, /ws/chat. The socket carries JSON
text frames in both directions. The client sends commands, for example to start a turn, add input
to a turn, stream microphone audio, or control a browser. The HQ sends back the turn lifecycle,
agent events, voice state, and browser frames.
| Endpoint | Listener | Clients | Credential |
|---|---|---|---|
ws://127.0.0.1:9200/ws/chat | HQ channel server, loopback only. Set the port with --channel-port (default 9200) | Desktop on the same computer | Chat token in the Authorization header, or ?token= or ?ticket= when there is no header |
wss://<gatewayId>.<zone>/ws/chat | Hosted relay, port 443 | iOS app, Android app, and web app | Relay pairing credential, then the same chat token rules. See Relay API |
Port 9200 applies to a standalone HQ. An HQ that Desktop starts uses a free port. It lists the port as channelPort in ~/.dashsquad/<hq id>/ports.json. See HQ ports.
The HQ does not listen on the network. Remote clients connect through the hosted relay. The HQ dials out to the relay and connects each relayed socket to its loopback channel server. For this reason, the frames and rules on this page are the same for a relayed socket and a loopback socket.
The chat token (DASHSQUAD_HQ_CHAT_TOKEN) is also the bearer of the HQ Mobile API.
The HQ accepts it on /ws/chat and /mobile/v1. The HQ never accepts the management token
(DASHSQUAD_HQ_TOKEN) on /ws/chat.
No LAN listener
Older HQ versions had a pinned-TLS listener for phones on the local network. The HQ
does not have this listener now. The HQ ignores the --lan-port flag and writes a warning to its
log.
Authentication
The HQ checks the credentials when it receives the upgrade request. These rules apply on every
/ws/chat endpoint:
- If the request has a non-empty
Authorizationheader, the header must beBearer <chat-token>. - If the request has an
Authorizationheader, the HQ ignores the query parameters. - If there is no
Authorizationheader, the HQ accepts?token=<chat-token>or a valid?ticket=<ticket>. - If the credentials are not correct, the HQ completes the upgrade and closes the socket with code
4001.
The reason of the 4001 close is Unauthorized. If the HQ starts without a chat token, it does
not check credentials on /ws/chat.
Native clients send the header, so the token is not in the URL:
GET /ws/chat HTTP/1.1
Upgrade: websocket
Authorization: Bearer <your-chat-token>Desktop on loopback can use the query parameter:
ws://127.0.0.1:9200/ws/chat?token=<your-chat-token>WebSocket tickets
A browser cannot set headers on a WebSocket upgrade, and the web app must not put the chat token in a URL. For this reason, the web app gets a single-use ticket over HTTP with the chat token:
POST /mobile/v1/ws-ticket
Authorization: Bearer <your-chat-token>{"ticket":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","expiresAt":"2026-08-29T12:00:30.000Z"}Then the web app connects with
wss://<host>/ws/chat?ticket=<ticket>. A ticket has these properties:
- It has 64 hexadecimal characters.
- It expires 30 seconds after the HQ makes it.
- The first try to redeem it uses it, also when that try is too late.
- It works on any
/ws/chatendpoint of the HQ that made it. One ticket store serves the full HQ. - If the upgrade also has an
Authorizationheader, the HQ uses the header and does not redeem the ticket.
See HQ Mobile API for the other /mobile/v1 routes.
Handshake
Before it opens the socket, a client reads GET /mobile/v1/health and requires
contractVersion: 1. See HQ Mobile API.
The first frame on every socket must be hello. It names contract version 1. It can also list
the client capabilities:
{"type":"hello","contractVersion":1,"capabilities":["provider-recovery-v1","chat-archive-v1"]}The HQ answers with hello_ack. It has the same two fields:
{"type":"hello_ack","contractVersion":1}Wait for hello_ack before you send another frame. The HQ closes the socket with code 1002 in
these conditions:
- The first frame is not
hello(hello_required). contractVersionis not1(unsupported_version). The HQ checks the version before the other fields.hellohas an unknown field (invalid_frame). The known fields aretype,contractVersion, andcapabilities.capabilitieshas an unknown value, or the same value two times (invalid_frame).- A second
helloarrives (unexpected_hello).
The HQ sends every server frame to every socket that subscribes to the conversation. This includes
browser_session and subagent_progress. A client does
not ask for these frames.
Voice mode needs the speech-v1 capability in GET /mobile/v1/health. No
capability advertises the browser live view. A client sends
browser_view_start and handles browser_view_error with code: "unavailable".
Compatibility rules:
- Unknown server frame types. Clients reject a server frame whose
typethey do not know. iOS closes the socket and shows Update required. Desktop stops the active turn and tells you to update dashsquad. Thus a new server frame type breaks older clients. Update the clients before the HQ that sends it. - Unknown
eventvariants. Clients must preserve or safely ignore agent events they do not recognize, so newer HQs remain compatible. - Unknown enum values. Clients decode
voice_state.state,voice_stopped.reason, and thebrowser_view_*enums leniently, so a new value does not break the socket.
The HQ no longer accepts the older socket protocol that started without hello, with frames such
as resume, watch, subscribe, follow_up, and edit_pending. A client that sends one of
them first is closed with 1002 hello_required. Update the client.
Connection lifecycle
One socket carries at most one conversation subscription, plus one voice session and one browser
viewer. A new subscribe_conversation replaces the earlier subscription of that socket. To follow
a second conversation, open a second socket.
Subscribe before you send
- Read
GET /mobile/v1/conversations/:id/bootstrapfor the conversation, its messages, its queue, andthroughSeq. - Open the socket, send
hello, and wait forhello_ack. - Send
subscribe_conversationwith thethroughSeqfrom the bootstrap, or with the lastseqyou applied. - The HQ replays every sequenced frame after that cursor, then sends
conversation_subscribed. After that, it sends each new frame live.
A run reaches a socket only through its subscription. Every turn on the conversation arrives this
way. This includes turns from this device and from other devices, and turns from the queue. It
also includes notification turns that the HQ starts (origin: "notification") and sub-agent
child turns (origin: "parent"). The subscription lasts across runs. A terminal frame does not end it.
Runs and sequences
- The client sends
messagewith a client-generated runidandresumable: true. - The HQ persists the user and assistant messages and sends
accepted.acceptedalways precedes the run's firstevent. - The HQ streams
eventframes: text deltas, tool calls, questions, and a finalresponse. - The run ends with exactly one terminal frame:
done(completed,cancelled, orinterrupted) orerror.
A delivered Steer starts a new segment inside the same run. Every frame of a run carries runId
and the segmentTurnId of its segment.
Every sequenced frame carries a durable seq. The HQ writes the frame to the conversation's log
before it sends it. Sequences are per conversation and increase across runs. Control frames
(hello_ack, conversation_subscribed, command_rejected, subagent_progress,
browser_session, and the voice and live-view frames) do not carry the conversation sequence and
are never replayed.
voice_speech, voice_audio, voice_played, browser_view_ack, and browser_view_frame have a
seq of their own. It counts the chunks of one stream. It is not the conversation sequence.
Reconnect
Closing a socket only detaches it. Provider work continues on the HQ. To catch up after a gap,
open a new socket, send hello, and send subscribe_conversation with the last seq you
applied. Store a cursor only after you apply its frame. You can also read the gap over HTTP with
GET /mobile/v1/agents/:agentId/conversations/:conversationId/events?sinceSeq=<last-seq>.
Do not send a message again with a new run id. You can send the same message again with the
same id. The HQ does not run it two times.
Cancellation
Send cancel with the run id. A cancelled run ends with done and outcome: "cancelled". If
Follow Up messages wait, the HQ pauses the queue and sends queue_paused. The next Follow Up
starts only after resume_follow_ups.
To stop only the tool call that runs now, send cancel_step. The run continues.
Steer and Follow Up
While a run is active, a client queues more input with enqueue_input:
behavior: "steer"delivers the message into the active run at the next safe agent boundary.behavior: "followUp"queues the message as a later run. Follow Up messages run in order, one at a time.behavior: "auto"lets the HQ choose.
edit_follow_up and remove_follow_up change a queued Follow Up message that has not started.
A failed run pauses the queue. A cancel also pauses it if Follow Ups wait. resume_follow_ups
resumes it. Every queue change is a sequenced
input_* or queue_* frame, sent to every subscriber.
For the limits, the auto-delivery rules, and how to show these messages in a client, see Steer and Follow Up.
Heartbeats and timeouts
The HQ sends no application-level heartbeat on /ws/chat and enforces no idle timeout on it. A
client that needs liveness detection must provide its own, such as WebSocket pings. Two timers
apply inside features: the voice drain timer (8 seconds) and the browser-view safety
acknowledgement (5 seconds).
Client frames
Every client frame is a JSON object with a string type. Every frame except hello also has a
string id.
Common field rules:
idis client-generated. Commands and subscriptions use a UUID. Formessage,answer,cancel, andcancel_stepit is the run ID. For voice and browser frames it is the session or viewer ID.conversationIdis a UUID, orsub_plus a 26-character ULID for a sub-agent conversation.imagesis an array of{ "mediaType", "data" }objects, wheredatais standard base64. The HQ accepts at most four images of typeimage/jpeg,image/png,image/gif, orimage/webp. Each decoded image is at most 5 MiB, and all decoded images together are at most 12 MiB.
hello
Opens the protocol. Must be the first frame. See Handshake.
| Field | Type | Required | Description |
|---|---|---|---|
type | "hello" | Yes | |
contractVersion | 1 | Yes | |
capabilities | string[] | No | The capabilities that this client can read: long-horizon-v1, provider-recovery-v1, chat-archive-v1, chat-tools-v1. Each value one time, at most 8 |
Send a capability only when GET /mobile/v1/health lists it. An HQ that does not know a value
closes the socket with 1002 invalid_frame. Without a capability, the HQ sends this socket the
older shapes. See Client capabilities.
subscribe_conversation
Replays a conversation from a cursor, then delivers its frames live.
| Field | Type | Required | Description |
|---|---|---|---|
type | "subscribe_conversation" | Yes | |
id | string | Yes | UUID. Echoed on conversation_subscribed |
agentId | string | Yes | Agent that owns the conversation |
conversationId | string | Yes | Conversation to subscribe to |
sinceSeq | integer | Yes | Last seq applied, or 0 |
{"type":"subscribe_conversation","id":"00000000-0000-4000-8000-000000000002","agentId":"agent-01","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","sinceSeq":0}message
Starts a run when the conversation is idle.
| Field | Type | Required | Description |
|---|---|---|---|
type | "message" | Yes | |
id | string | Yes | The run ID. Reuse it for answer and cancel |
agentId | string | Yes | Target agent |
channelId | string | Yes | Client channel label, such as mobile-ios or direct |
conversationId | string | Yes | Conversation to append to. It must exist and belong to agentId |
text | string | Yes | The user's message |
resumable | true | Yes | Always true |
images | array | No | Image attachments. See the common rules above |
location | object | No | The device's time zone and, optionally, precise position. See below |
questionAnswers | object | No | The answers to a question card, as toolCallId and answers |
retryOf | string | No | Only with provider-recovery-v1. The run ID of a failed turn of this conversation. This run sends that turn again. See below |
location fields:
| Field | Type | Required | Description |
|---|---|---|---|
timezone | string | Yes | IANA time zone, such as Asia/Singapore |
utcOffsetMinutes | integer | Yes | Minutes east of UTC at send time, from -840 to 840. Singapore is 480 |
locale | string | Yes | Locale identifier, such as en-SG |
region | string | No | ISO 3166-1 alpha-2 region code |
precise | object | No | latitude, longitude, accuracyMeters, and capturedAt (RFC 3339) are required; place is optional |
JavaScript's getTimezoneOffset() is west-positive and must be negated.
{"type":"message","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab","agentId":"agent-01","channelId":"mobile-ios","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","text":"Is the mobile connection ready?","location":{"timezone":"Asia/Singapore","utcOffsetMinutes":480,"locale":"en-SG","region":"SG"},"resumable":true}If the conversation already has an active run, the HQ answers with command_rejected, code
conversation_busy. Use enqueue_input instead.
A retry with retryOf is safe only when the failed turn did not run tools. The HQ checks
providerFailure.toolEffects of that turn (see error):
- If
retryOfdoes not name a failed turn of this conversation, the HQ answerscommand_rejectedwithcode: "validation_failed". - If
toolEffectsis notnone, the HQ answerscommand_rejectedwithcode: "validation_failed"anddetails.reason: "unsafe_retry". Some actions of the turn can be complete already. Tell the user to check the chat and send again.
If the text is a /goal control, such as /goal pause, the HQ starts no run. It answers
command_rejected with code: "validation_failed" and details.goalCommand: true. error holds
the answer to show to the user.
enqueue_input
Queues a Steer or Follow Up message.
| Field | Type | Required | Description |
|---|---|---|---|
type | "enqueue_input" | Yes | |
id | string | Yes | Command UUID |
inputId | string | Yes | Input UUID. Use it later to edit or remove the input |
agentId | string | Yes | Agent that owns the conversation |
channelId | string | Yes | Client channel label |
conversationId | string | Yes | Conversation to queue on |
text | string | Yes | Message text |
behavior | "steer" | "followUp" | "auto" | Yes | How to deliver the message |
expectedActiveTurnId | string | For steer and auto | The run the client believes is active |
images | array | No | Image attachments |
{"type":"enqueue_input","id":"218f0f4a-5c42-7a8b-9c01-1234567890ab","inputId":"818f0f4a-5c42-7a8b-9c01-1234567890ab","agentId":"agent-01","channelId":"mobile-ios","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","text":"Also compare the migration cost.","behavior":"followUp"}When you retry an uncertain command, reuse both id and inputId with exactly the same payload.
The HQ rejects a reused ID with a different payload.
edit_follow_up
Replaces the text and images of a queued Follow Up message that has not started.
| Field | Type | Required | Description |
|---|---|---|---|
type | "edit_follow_up" | Yes | |
id | string | Yes | Command UUID |
conversationId | string | Yes | Conversation that holds the input |
inputId | string | Yes | The input to edit |
expectedRevision | integer | Yes | The input's current revision |
text | string | Yes | Replacement text |
images | array | No | Replacement image set |
remove_follow_up
Removes a queued Follow Up message that has not started.
| Field | Type | Required | Description |
|---|---|---|---|
type | "remove_follow_up" | Yes | |
id | string | Yes | Command UUID |
conversationId | string | Yes | Conversation that holds the input |
inputId | string | Yes | The input to remove |
expectedRevision | integer | Yes | The input's current revision |
resume_follow_ups
Resumes a paused queue.
| Field | Type | Required | Description |
|---|---|---|---|
type | "resume_follow_ups" | Yes | |
id | string | Yes | Command UUID |
conversationId | string | Yes | Conversation whose queue to resume |
expectedQueueRevision | integer | Yes | The queue's current queueRevision |
answer
Answers a question event from the active run.
| Field | Type | Required | Description |
|---|---|---|---|
type | "answer" | Yes | |
id | string | Yes | The run ID that asked |
questionId | string | Yes | The id of the question event |
answer | string | Yes | The chosen option or free text |
{"type":"answer","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab","questionId":"question-01","answer":"Yes"}cancel
Stops the active run.
| Field | Type | Required | Description |
|---|---|---|---|
type | "cancel" | Yes | |
id | string | Yes | The run ID to cancel |
{"type":"cancel","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab"}cancel_step
Stops the tool call (or calls) that the active run does now, but not the run. Needs
cancel-step-v1 in GET /mobile/v1/health. The model gets "Stopped by the user." as the error
of the call. Then the run continues. A Steer message that waits goes in next.
| Field | Type | Required | Description |
|---|---|---|---|
type | "cancel_step" | Yes | |
id | string | Yes | The run ID |
{"type":"cancel_step","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab"}If no tool call runs, the HQ sends command_rejected with code: "validation_failed" and
No step is running. to this socket only. If the run is not active, the code is not_found.
Voice and browser client frames
The voice_* client frames are documented under Voice mode, and the
browser_view_* client frames under Browser live view.
Server frames
Server frames are JSON objects with a string type. There are two groups:
- Sequenced frames carry
seq, are stored in the conversation's log, and are replayed:accepted,event,done,error,input_accepted,input_updated,input_removed,input_delivered,input_failed,queue_paused, andqueue_resumed. - Control frames do not carry the conversation sequence and are never replayed:
hello_ack,conversation_subscribed,command_rejected,subagent_progress,browser_session, and the voice and live-view frames.
hello_ack
Answers hello. It has exactly two fields: type and contractVersion (1).
conversation_subscribed
Sent after the replay of a subscribe_conversation.
| Field | Type | Required | Description |
|---|---|---|---|
type | "conversation_subscribed" | Yes | |
id | string | Yes | The subscribe_conversation frame's id |
conversationId | string | Yes | Subscribed conversation |
throughSeq | integer | Yes | The last seq replayed. Live frames follow |
If the agent has a browser open in the conversation, the HQ then sends a
browser_session frame with state: "open" to this socket.
accepted
The HQ admitted a run, or a new segment of a run, and persisted its user and assistant messages.
| Field | Type | Required | Description |
|---|---|---|---|
type | "accepted" | Yes | |
id | string | Yes | Run ID |
conversationId | string | Yes | Conversation the run is in |
runId | string | Yes | Run ID, the same as id |
segmentTurnId | string | Yes | The segment this frame belongs to |
seq | integer | Yes | Durable sequence |
userMessageId | string | Yes | ID of the persisted user message |
assistantMessageId | string | Yes | ID of the assistant message the segment fills |
revision | integer | Yes | Conversation revision after acceptance |
origin | string | No | Who started the run. Live frames only. The same values as the message origin (see Messages). The HQ sends the newer values only to a client that declares their capability |
kind | "user" | "subagent" | No | Conversation kind. Live frames only |
requestId | string | No | Correlation ID, such as the requestId of a sub-agent resume. Live frames only |
retryOf | string | No | The retryOf of the message. Live frames only, and only with provider-recovery-v1 |
Replayed accepted frames never carry origin, kind, requestId, or retryOf. Read the durable origin
and kind from the message and the conversation.
{"type":"accepted","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","runId":"018f0f4a-5c42-7a8b-9c01-2234567890ab","segmentTurnId":"018f0f4a-5c42-7a8b-9c01-2234567890ab","seq":1,"userMessageId":"018f0f4a-5c42-7a8b-9c01-3234567890ab","assistantMessageId":"018f0f4a-5c42-7a8b-9c01-4234567890ab","revision":2}event
One agent event from the run. Has id, conversationId, runId, segmentTurnId, seq, and
event. event is an object with a string type.
{"type":"event","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","runId":"018f0f4a-5c42-7a8b-9c01-2234567890ab","segmentTurnId":"018f0f4a-5c42-7a8b-9c01-2234567890ab","seq":2,"event":{"type":"text_delta","text":"Ready "}}Agent event types:
event.type | Fields | Meaning |
|---|---|---|
text_delta | text | A part of the reply |
thinking_delta | text | A part of the model thinking |
tool_use_start | id, name, input (optional) | A tool call started |
tool_use_delta | partial_json | Streamed tool input |
tool_result | id, name, content, isError, details, images (the last three are optional) | A tool call finished |
response | content, usage | The final assistant message and the token usage (inputTokens, outputTokens, and optional cacheReadTokens and cacheWriteTokens) |
question | id, question, options | The agent waits for an answer. The current runtime does not send it |
agent_retry | attempt, reason | The HQ tries the provider call again |
context_compacted | overflow | The HQ made the conversation context smaller |
file_changed | files | Files that the turn changed |
agent_spawned | name | The agent started a sub-agent |
subagent_started | subagentId, subagentType, description, prompt, model, background, depth, startedAt, and optional name, isolation, parentTurnId | A sub-agent started |
subagent_finished | subagentId, subagentType, description, status, report, toolCallCount, startedAt, endedAt, and optional name, usage | A sub-agent stopped |
skill_loaded | name | The agent loaded a skill |
skill_created | name, description | The agent made a skill |
memory_saved | name, description, memoryType, action (created or updated) | The agent saved a memory |
memory_forgotten | name | The agent deleted a memory |
mcp_server_error | server, error | A connected MCP server failed |
A model error does not arrive as an error event. The run ends with an error frame.
Sub-agent heartbeats arrive as subagent_progress frames. The HQ can add new event types at any time, so ignore the types
that you do not know.
done
The run ended. Has id, conversationId, runId, segmentTurnId, seq, and outcome:
completed, cancelled, or interrupted (an HQ restart stopped the run).
{"type":"done","id":"018f0f4a-5c42-7a8b-9c01-2234567890ab","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","runId":"018f0f4a-5c42-7a8b-9c01-2234567890ab","segmentTurnId":"018f0f4a-5c42-7a8b-9c01-2234567890ab","seq":5,"outcome":"completed"}error
The run failed. Has id, conversationId, runId, segmentTurnId, seq, and error (a
human-readable message), and optionally code and retryable. It ends the run.
With provider-recovery-v1, the frame can also have providerFailure. It tells why the provider
refused the turn, so a client can offer the correct repair. The stored error event of the
assistant message has the same object. It never holds a secret, a header, or a response body.
| Field | Type | Description |
|---|---|---|
category | string | missing_credential, invalid_key, oauth_expired, oauth_revoked, permission, model_unavailable, client_outdated, rate_limit, quota, network, or unknown. The list is open: show a value that you do not know as unknown |
provider | string | The route of the failed call, for example openrouter. This is not always the maker of the model |
model | string | The model of the failed call |
credentialSlot | string or null | The key name, for example default or work. null when the HQ cannot tell which key it used. Then ask the user which key to repair |
authMethod | "api_key", "oauth", or null | How the HQ signed in to the provider |
credentialRevision | integer or null | Increases each time the key in this slot changes. Do not let a repair for an older revision replace a newer key |
retryAfterMs | integer | Optional. From the Retry-After of the provider, when known |
toolEffects | "none", "completed", or "unknown" | none: a retry of the whole turn is safe. Otherwise the HQ refuses retryOf |
detail | string | Optional. One short sentence, at most 300 characters |
refresh | object | Optional, for oauth_expired. state (renewing, renewed, failed_temporary, or revoked) and at (a time). The HQ renews the sign-in and reports the result here |
client_outdated means that the provider refuses this version of dashsquad. Only a newer version
of dashsquad can fix it. The other value lists in providerFailure are closed.
Input and queue frames
type | Sent when |
|---|---|
input_accepted | The HQ queued an enqueue_input |
input_updated | An edit_follow_up changed a queued input |
input_removed | A remove_follow_up removed a queued input |
input_delivered | The input was delivered. Also has runId, segmentTurnId, userMessageId, and assistantMessageId |
input_failed | The input could not be delivered |
queue_paused, queue_resumed | The queue was paused or resumed. Has queuePaused and pendingFollowUpCount |
Every input frame has id (the command ID), conversationId, seq, queueRevision, and
input, the full input in the same shape as pendingInputs on the
bootstrap. Use queueRevision to discard
stale queue state.
command_rejected
The HQ refused a command. Sent only to the socket that sent it. A rejection does not close the socket or end the subscription.
| Field | Type | Required | Description |
|---|---|---|---|
type | "command_rejected" | Yes | |
id | string | Yes | The rejected frame's id |
conversationId | string | No | Present when the frame named one |
code | string | Yes | Machine-readable error code |
error | string | Yes | Human-readable message |
retryable | boolean | Yes | true if the same command can succeed when you send it again |
details | object | No | Extra context, such as suggested and confidence on delivery_choice_required |
{"type":"command_rejected","id":"018f0f4a-5c42-7a8b-9c01-5234567890ab","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","code":"conversation_busy","error":"Conversation has an active turn","retryable":true,"details":{"activeTurnId":"018f0f4a-5c42-7a8b-9c01-2234567890ab"}}subagent_progress
A heartbeat from a sub-agent that runs under the active run. Sent to every socket that is subscribed to the conversation.
| Field | Type | Required | Description |
|---|---|---|---|
type | "subagent_progress" | Yes | |
id | string | Yes | The parent run ID |
conversationId | string | Yes | The parent conversation |
runId | string | Yes | The parent run ID |
event | object | Yes | type: "subagent_progress", subagentId, status, toolCallCount, elapsedMs, and optional detail and question |
Voice and browser server frames
The voice_* server frames are documented under Voice mode, and the
browser_view_* and browser_session frames under Browser live view.
Question cards
An agent asks the user multiple-choice questions with the ask_user_question tool. The questions
are in the input of the tool_use_start event for that tool. A client shows them as a card. The
tool ends the turn. The answers of the user start the next turn.
{"type":"tool_use_start","id":"toolu_01abc","name":"ask_user_question","input":{"questions":[{"question":"Which DB?","header":"Database","multiSelect":false,"options":[{"label":"Postgres","description":"Use the managed Postgres service.","recommended":true},{"label":"SQLite","description":"Keep a local file database."}]}]}}The tool input has these rules:
- 1 to 4 questions. Each question has
question,header(a maximum of 12 characters),multiSelect, andoptions. - 2 to 4 options for each question. Each option has
label,description, and optionalrecommended. - Question text has a maximum of 2,000 Unicode code points. Each option label has a maximum of 500.
- A maximum of one recommended option for each question. The HQ puts it first.
- No option with the label "Other". The user can always type free text.
If the input is correct, the tool_result has details with kind: "question_card", version: 1,
and the questions. If the input is not correct, the tool_result has isError: true and the turn
continues.
To answer, send a new message. Put a readable
summary in text and the answers in questionAnswers:
{"type":"message","id":"00000000-0000-4000-8000-000000000301","agentId":"agent-01","channelId":"mobile","conversationId":"00000000-0000-4000-8000-000000000001","text":"Which DB?\n→ Postgres\n\nWhich clients?\n→ iOS, Web; Android later","questionAnswers":{"toolCallId":"toolu_01abc","answers":[{"question":"Which DB?","selected":["Postgres"]},{"question":"Which clients?","selected":["iOS","Web"],"other":"Android later"}]},"resumable":true}| Field | Type | Required | Description |
|---|---|---|---|
toolCallId | string | Yes | The id of the tool_use_start event. 1 to 200 characters |
answers | array | Yes | 1 to 4 answers, one for each question |
answers[].question | string | Yes | The question text. A maximum of 2,000 characters |
answers[].selected | array of strings | Yes | The labels of the selected options. A maximum of 4 labels, each a maximum of 500 characters |
answers[].other | string | No | Free text. 1 to 2,000 characters |
The HQ counts lengths in Unicode code points. questionAnswers must have only the
fields in this table.
- To skip the questions, send a
messagewith the textSkipped your questions.and noquestionAnswers. - A card has the answered state if the next user message has the same
toolCallId. Any other next user message replaces the card. - Message reads always include
questionAnswerswhen a reply has them.
Voice mode
Voice mode runs a hands-free spoken conversation on the same socket. The client streams
microphone audio. The HQ finds each utterance, transcribes it, runs it as a turn, and
streams the spoken reply back. Voice mode requires the speech-v1 capability.
Every voice_* frame in both directions has the session id. The client selects this id in
voice_start. A socket holds a maximum of one voice session.
Session lifecycle
- Send
voice_start. The HQ checks the speech configuration, the conversation, and the speech provider. - Read
voice_statewithstate: "listening". - Stream
voice_audio. The HQ drops audio that arrives beforelistening. - When the user stops, read
voice_statetranscribingand a finalvoice_transcriptwith aturnId. - Read
voice_statethinkingand theaccepted,event, anddoneframes of the turn. - Read
voice_statespeakingand thevoice_speechparts of the reply. - Send
voice_playedas playback finishes. - Read
voice_statelisteningagain, orvoice_stoppedwhen the session ends.
The voice turn is a turn with that turnId, channelId: "ios", and
modality: "voice". The voice_transcript with the turnId always arrives before the
accepted of the turn. For this reason, the client can show the user row first. The turn frames arrive through the
conversation subscription, so subscribe to the conversation before voice_start. The accepted
usually arrives just before thinking.
After the HQ speaks for 300 ms or more, speech from the user interrupts it (barge-in). The HQ
cancels the active turn, discards the queued speech, and goes back to listening. That
utterance becomes the next turn.
An utterance during a turn that does not interrupt it goes into a queue. The HQ sends its
voice_transcript without a turnId when it hears the utterance. It sends the transcript again
with a turnId when the turn of that utterance starts.
If the agent asks a question, the HQ speaks it and sends the next utterance as the
answer. That transcript has no turnId.
voice_start
Client frame. Starts a session. A second voice_start on the same socket first stops the active
session with voice_stopped and reason: "replaced".
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_start" | Yes | |
id | string | Yes | Session ID. The HQ echoes it on every voice_* frame |
agentId | string | Yes | Agent to talk to |
conversationId | string | Yes | Conversation that gets the voice turns |
{"type":"voice_start","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","agentId":"agent-01","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab"}The HQ reports failures with voice_error. The code is unavailable when the HQ has no speech
configuration or no available provider. The code is invalid when the conversation does not exist
or belongs to a different agent.
voice_audio
Client frame. One part of the microphone capture.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_audio" | Yes | |
id | string | Yes | Session ID |
seq | integer | Yes | Client counter, 0 or more. The HQ does not reorder with it: it reads the parts in the order of arrival |
pcm | string | Yes | Standard base64 of 16 kHz mono PCM16. A maximum of 16,384 decoded bytes (approximately 512 ms) |
{"type":"voice_audio","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","seq":42,"pcm":"AAAAAAAAAAA="}If pcm is too large, the HQ answers with an ordinary error frame and code validation_failed,
not with voice_error. The HQ drops audio while the session is muted.
voice_mute
Client frame. Mutes or unmutes the microphone. Playback continues.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_mute" | Yes | |
id | string | Yes | Session ID |
muted | boolean | Yes | true to mute |
{"type":"voice_mute","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","muted":true}When you mute, the HQ sends voice_state muted. It sends no other voice_state while the
session is muted. When you unmute, the HQ sends the state that the session got to while it was
muted.
voice_played
Client frame. Each voice_speech up to and with this seq has finished playback.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_played" | Yes | |
id | string | Yes | Session ID |
seq | integer | Yes | Highest voice_speech seq that the client played |
{"type":"voice_played","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","seq":4}After the last voice_speech of a turn, the HQ stays in speaking. It waits for a voice_played
with a seq that is the same or higher, or for an 8-second safety timer. If a client stops playback
when the session leaves speaking, it must send voice_played. If it does not, the user does not
hear the last sentence of each reply.
The HQ ignores an old or out-of-order seq. Do not acknowledge audio that a barge-in discarded.
voice_stop
Client frame. Stops the session. If no session is active, this is not an error.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_stop" | Yes | |
id | string | Yes | Session ID |
{"type":"voice_stop","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab"}The HQ cancels the active voice turn and sends voice_stopped with reason: "client". If the
session stops before its first listening, the HQ sends no voice_stopped.
If there is no session on the socket, the HQ answers voice_audio, voice_mute, and
voice_played with voice_error and code invalid.
voice_state
Server frame. The phase of the session.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_state" | Yes | |
id | string | Yes | Session ID |
state | string | Yes | listening, transcribing, thinking, speaking, muted, or stopped |
turnId | string | No | The turn of the state, on thinking and speaking |
{"type":"voice_state","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","state":"thinking","turnId":"018f0f4a-5c42-7a8b-9c01-7234567890ab"}stopped is a valid value, but the HQ never sends it. Each session ends with a voice_stopped
frame.
voice_transcript
Server frame. The text that the HQ recognized from the user's speech.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_transcript" | Yes | |
id | string | Yes | Session ID |
text | string | Yes | The transcript |
final | boolean | Yes | true for a final transcript. The HQ sends only final transcripts now |
turnId | string | No | Present on the transcript that starts a turn |
{"type":"voice_transcript","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","text":"What's the weather like today?","final":true,"turnId":"018f0f4a-5c42-7a8b-9c01-7234567890ab"}If an utterance gives no text, the HQ sends no transcript and starts no turn.
voice_speech
Server frame. A part of the spoken reply.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_speech" | Yes | |
id | string | Yes | Session ID |
seq | integer | Yes | Counter for the session, from 0. It does not go back to 0 between turns |
audio | string | Yes | Base64 audio bytes |
format | "mp3" | "pcm16" | Yes | mp3: one full clip for each sentence. pcm16: raw parts of a sentence |
sampleRate | integer | No | Sample rate of pcm16 audio |
text | string | Yes | Caption for the sentence. For pcm16, only the first part of a sentence has it. Later parts send "" |
{"type":"voice_speech","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","seq":3,"audio":"AAAAAAAAAAAAAAAAAAAAAA==","format":"pcm16","sampleRate":24000,"text":"It's sunny and 24 degrees."}voice_state speaking always comes before the first voice_speech of a turn.
voice_error
Server frame. A speech failure or a session failure.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_error" | Yes | |
id | string | Yes | Session ID |
code | string | Yes | unauthorized, unavailable, too_long, too_large, invalid, provider, or network |
error | string | Yes | Human-readable message |
{"type":"voice_error","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","code":"provider","error":"The speech provider failed to transcribe the clip"}Most errors do not stop the session:
- If a transcription fails or takes more than 20 seconds (
network), the HQ drops that utterance and goes back tolistening. - If synthesis fails with
unavailableornetwork, the HQ skips that sentence. - If a turn fails, the HQ reports
providerafter its partial speech plays.
These failures stop the session with voice_stopped and reason: "provider":
- Three synthesis failures in sequence, or one synthesis failure with a different code.
- A turn that the HQ rejects because the conversation is busy (
unavailable), missing, or not authorized (invalid).
voice_stopped
Server frame. The session ended. No more voice_* frames come for this id.
| Field | Type | Required | Description |
|---|---|---|---|
type | "voice_stopped" | Yes | |
id | string | Yes | Session ID |
reason | string | Yes | client (voice_stop), replaced (a new voice_start), provider (a failure that the HQ cannot recover from), or socket (the socket closed) |
{"type":"voice_stopped","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","reason":"client"}Browser live view
When an agent opens a browser in a conversation, a client can watch it live and take control of the input. The view is a JPEG screencast of the active tab of the session. The frames and the viewer input are for the person who watches. The agent never sees them.
Every browser_view_* frame has the viewer id that the client selected in browser_view_start.
There is no capability flag. If the HQ has no live view, it answers every browser_view_* frame
with browser_view_error and code: "unavailable".
Viewer rules
- One viewer for each socket. A second
browser_view_starton the same socket stops the first viewer withreason: "replaced". - One viewer for each browser session, on the full HQ. A viewer on a different socket replaces this viewer in the same way.
- Flow control. A maximum of one
browser_view_framewaits for an acknowledgement at a time. While it waits, the HQ keeps only the newest frame and drops the frames between. - Acknowledgements. Acknowledge each frame with
browser_view_ack. If the client does not acknowledge a frame in 5 seconds, the HQ acknowledges it and sends the next frame. - Control.
controlis"agent"while a turn is active in the conversation, and"user"at other times. The HQ examines it every second and before each input. - Control changes. The HQ sends
browser_view_statewhencontrolchanges. The HQ drops input that arrives whilecontrolis"agent". - Stop. The view stops with
reason: "session_closed"when the browser closes. It stops with"socket_closed"when the socket closes.
browser_view_start
Client frame. Starts a view of the browser of the conversation.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_start" | Yes | |
id | string | Yes | Viewer ID. The HQ echoes it on every browser_view_* frame |
agentId | string | Yes | Agent that owns the conversation |
conversationId | string | Yes | Conversation with the browser to view |
maxWidth | integer | No | Largest frame width, in pixels. The HQ keeps it from 320 to 1920. The default is 1280 |
{"type":"browser_view_start","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","agentId":"agent-01","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","maxWidth":960}The HQ answers with browser_view_started or with browser_view_error:
invalidwhen the conversation does not belong to the agent.no_sessionwhen no browser is open in the conversation.unavailablewhen the view does not start.
browser_view_ack
Client frame. Acknowledges a browser_view_frame.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_ack" | Yes | |
id | string | Yes | Viewer ID |
seq | integer | Yes | The seq of the frame to acknowledge |
{"type":"browser_view_ack","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","seq":42}The HQ ignores an acknowledgement for a frame other than the frame that waits.
browser_view_input
Client frame. Sends pointer, keyboard, text, or scroll input to the page. It has an effect only
while control is "user".
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_input" | Yes | |
id | string | Yes | Viewer ID |
input | object | Yes | One of the input kinds below |
input.kind | Fields |
|---|---|
pointer | x, y (numbers, in frame pixels), phase (down, up, or move), optional button (left, middle, or right) |
key | key (DOM key name, a maximum of 32 characters), phase (down or up), optional code (DOM code, a maximum of 32 characters), optional modifiers (integer bitmask) |
text | text (a maximum of 4,096 characters). The HQ inserts it as typed text |
scroll | x, y (numbers, in frame pixels), dx, dy (scroll distances, in frame pixels) |
The HQ scales frame coordinates and scroll distances to the page. modifiers uses the Chrome DevTools Protocol bitmask:
Alt 1, Ctrl 2, Meta 4, Shift 8.
{"type":"browser_view_input","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","input":{"kind":"pointer","x":120.5,"y":340,"phase":"down","button":"left"}}{"type":"browser_view_input","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","input":{"kind":"text","text":"hello dash"}}browser_view_stop
Client frame. Stops the view with this viewer id. If no view with this id is active, the HQ
ignores the frame. This is not an error.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_stop" | Yes | |
id | string | Yes | Viewer ID |
{"type":"browser_view_stop","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab"}If the id names the active view, the HQ answers with browser_view_stopped and
reason: "client".
If the viewer id is not active, the HQ answers browser_view_ack and browser_view_input with
browser_view_error and code invalid.
browser_view_started
Server frame. The view is active.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_started" | Yes | |
id | string | Yes | Viewer ID |
width | integer | Yes | Frame width in pixels |
height | integer | Yes | Frame height in pixels |
url | string | Yes | Current page URL |
title | string | Yes | Current page title |
control | "agent" | "user" | Yes | Who can control the page |
{"type":"browser_view_started","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","width":960,"height":600,"url":"https://example.com/","title":"Example Domain","control":"agent"}No other frame for this viewer comes before it.
browser_view_frame
Server frame. One screencast frame.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_frame" | Yes | |
id | string | Yes | Viewer ID |
seq | integer | Yes | Frame counter. Acknowledge it with browser_view_ack |
data | string | Yes | Base64 JPEG |
width | integer | Yes | Frame width in pixels |
height | integer | Yes | Frame height in pixels |
{"type":"browser_view_frame","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","seq":7,"data":"AAAAAAAAAAA=","width":960,"height":600}browser_view_state
Server frame. The page or the control changed.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_state" | Yes | |
id | string | Yes | Viewer ID |
url | string | Yes | Current page URL |
title | string | Yes | Current page title |
control | "agent" | "user" | Yes | Who can control the page |
dialog | string | No | The message of an open dialog that blocks the page |
{"type":"browser_view_state","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","url":"https://example.com/login","title":"Sign in","control":"user","dialog":"Leave site? Changes you made may not be saved."}browser_view_stopped
Server frame. The view ended.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_stopped" | Yes | |
id | string | Yes | Viewer ID |
reason | string | Yes | client, replaced, session_closed, or socket_closed |
{"type":"browser_view_stopped","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","reason":"client"}browser_view_error
Server frame. A live-view request failed.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_view_error" | Yes | |
id | string | Yes | Viewer ID |
code | "unavailable" | "invalid" | "no_session" | Yes | See the frames above |
error | string | Yes | Human-readable message |
{"type":"browser_view_error","id":"018f0f4a-5c42-7a8b-9c01-8234567890ab","code":"no_session","error":"No browser session is open on this conversation."}browser_session
Server frame. The agent opened or closed a browser in a conversation. The HQ sends it to every socket that subscribes to the conversation. It often arrives during a turn. It has no sequence, and the HQ does not replay it.
When a socket subscribes to a conversation with an open browser, the HQ sends one
browser_session with state: "open" to that socket, after conversation_subscribed. Thus a
client that connects again can offer the live view immediately.
| Field | Type | Required | Description |
|---|---|---|---|
type | "browser_session" | Yes | |
id | string | Yes | A new ID for this notification. It does not identify a turn or a viewer |
agentId | string | Yes | Agent that owns the browser |
conversationId | string | Yes | Conversation of the browser |
state | "open" | "closed" | Yes | New state |
profile | string | No | Named browser profile of the session |
{"type":"browser_session","id":"018f0f4a-5c42-7a8b-9c01-9234567890ab","agentId":"agent-01","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","state":"open","profile":"e2e"}The HQ does not send a separate browser_session for the browser of a sub-agent.
Errors and close codes
Rejected commands
A well-formed frame that the HQ cannot act on gets a command_rejected frame. The socket stays
open and keeps its subscription.
| Condition | Frame |
|---|---|
A known frame type with a valid id but missing or invalid fields | command_rejected with code: "validation_failed" |
| A command the HQ refuses | command_rejected with a code from the table below |
| A voice request that fails | voice_error. See Voice mode |
| A live-view request that fails | browser_view_error. See Browser live view |
Codes on command_rejected:
code | retryable | Meaning |
|---|---|---|
validation_failed | false | The frame is malformed, a revision is stale, or a command id was reused with a different payload. Also a refused retry (details.reason: "unsafe_retry"), a cancel_step with no tool call, and a /goal control answer (details.goalCommand: true) |
not_found | false | The conversation does not exist or belongs to another agent. Or the run that the frame answers or cancels is not active |
conversation_busy | true | The conversation has an active run, or the HQ disables the agent now. details.activeTurnId names the run when the HQ knows it |
revision_conflict | false | A revision changed, or the Steer target is not active. Read the conversation again |
delivery_choice_required | false | The HQ could not choose for an auto enqueue. See Steer and Follow Up |
gateway_offline | true | An internal HQ failure. A request that arrives during HQ shutdown also gets this code |
The contract's MobileApiErrorCode list defines further codes shared with the HTTP API, so
treat any unknown code as a generic failure.
Close codes
| Code | Reason | When |
|---|---|---|
4001 | Unauthorized | The upgrade's credentials were rejected. Sent right after the upgrade completes |
1002 | hello_required | The first frame was not hello |
1002 | unsupported_version | hello named a contractVersion other than 1 |
1002 | unexpected_hello | A second hello arrived |
1002 | invalid_frame | The text was not valid JSON, the type is unknown, or the id is missing or malformed |
1012 | gateway_shutdown | The HQ stops. Connect again and subscribe again |
Through the relay, a device can also see the relay's own 4401 and 4429 closes. See
Relay API.
When a socket closes, the HQ stops that socket's voice session (reason: "socket"), stops its
browser viewer (reason: "socket_closed"), and drops its subscription. Runs continue.