HQ Mobile API
Wire reference for /mobile/v1: the HTTP and SSE routes that iOS, Android, and web clients use to reach dashsquad HQ.
The Mobile API is the versioned HTTP surface that native and browser clients use to talk to an HQ.
All routes are under /mobile/v1. It covers HQ health and identity, agents, conversations and their
history, sub-agents, AI provider keys, speech, and a Server-Sent Events stream of cache
invalidations. Live chat uses a separate WebSocket. See HQ Chat WebSocket.
The HQ does not serve /mobile/v2 now. Each path under it returns 404 with code not_found
before authentication. Each other /mobile path outside /mobile/v1 also returns this 404. A
client that gets this 404 is older than the HQ. Update the client.
Desktop uses the administrative routes on the loopback interface. The Management API page describes them. Those routes use a different bearer, and the relay never forwards them.
Overview
Listeners and base URLs
Clients reach the HQ through the hosted relay. The relay sends each request through the outbound tunnel of the HQ to the loopback management server. Both paths run the same authentication and return the same bodies.
| Path to the HQ | Base URL | Transport | Notes |
|---|---|---|---|
| Relay | https://<gatewayId>.<zone>/mobile/v1 | HTTPS on 443, terminated at the relay | The normal path for phones and the web app. See Relay API. |
| Loopback management server | http://127.0.0.1:9300/mobile/v1 | Plain HTTP on loopback | The relay replays requests here. Local tools can also call it directly. |
Port 9300 applies to a standalone HQ. An HQ that Desktop starts uses a free port. It lists the port as managementPort in ~/.dashsquad/<hq id>/ports.json. See HQ ports.
The HQ has no LAN listener. The HQ ignores the old --lan-port flag and writes a warning to its
log.
Pairing payload
A device pairs with a relay pairing payload, version 2. Desktop shows it as a QR code. The payload carries the relay host, the mobile bearer, and a per-device relay credential:
{
"v": 2,
"host": "gateway-01.relay.dash.example",
"secure": true,
"mgmtToken": "mobile-test-token",
"chatToken": "mobile-test-token",
"relayCredential": "relay-device-credential"
}| Field | Rule |
|---|---|
v | Must be 2. Clients reject the old plaintext version 1 payload and the removed version 3 (LAN) payload. |
host | The relay hostname of the HQ. No scheme, path, port separator, or whitespace. |
secure | Must be true |
mgmtToken, chatToken | Both carry the mobile bearer. Clients trim both, require them to be equal, and store one value. |
relayCredential | The per-device relay credential. Not blank. |
The payload can carry other non-secret fields. Clients ignore them.
Authentication
All routes need the mobile bearer of the HQ, except GET /mobile/v1/health:
Authorization: Bearer <mobile-token>- The mobile bearer is the phone-scoped token of the HQ. You set it with the
DASHSQUAD_HQ_CHAT_TOKENenvironment variable of the HQ. It is different from the management token (DASHSQUAD_HQ_TOKEN). - The HQ does not start if the two tokens are equal after a trim. In relay mode the HQ also does not start if one of the two tokens is blank.
- The HQ does not accept the management token on
/mobile/v1. The HQ does not accept the mobile token on an administrative route. - The header must be exactly
Bearerand then the token. The HQ does not trim the header and does not change its case. - Desktop writes the bearer into the pairing QR code. Clients that pair through a dashsquad account
get it as
chatTokenfrom the control plane. See Relay Control Plane API. - If the HQ has a management token but no mobile token, all mobile routes except
/healthreturn401. If the HQ has no token at all, authentication is off. That is possible only for an HQ without a relay.
A missing or wrong bearer gets this response:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{ "code": "unauthorized", "error": "Unauthorized", "retryable": false }Through the relay, also send the per-device relay credential on each request:
curl "https://$GATEWAY_ID.$RELAY_ZONE/mobile/v1/identity" \
-H "x-dash-relay-credential: $RELAY_CREDENTIAL" \
-H "Authorization: Bearer $MOBILE_TOKEN"The relay checks the credential and removes it, so the HQ never sees it. The relay forwards the
bearer without a change, and the HQ checks it. If the relay rejects a request, the body is
text/plain from the relay, not the JSON envelope. See
Relay API.
CORS and browser origins
Cross-origin access is off until the HQ has a web-origin allowlist. The HQ resolves the allowlist when it starts:
| Configuration | Allowed origins |
|---|---|
DASH_WEB_ORIGINS not set, HQ not enrolled on a relay | None. CORS is off. |
DASH_WEB_ORIGINS not set, HQ enrolled on a relay | https://app.<zone>. <zone> is the relay hostname without the label of the HQ. If <zone> starts with relay., the HQ also lets https://app.<domain> connect. <domain> is <zone> without relay.. |
DASH_WEB_ORIGINS set to a comma-separated list | Exactly that list. The HQ trims each entry and drops blank entries. To keep the default origin, put it in the list. |
DASH_WEB_ORIGINS set but empty | None. Use this to turn off browser access. |
If the allowlist is not empty, the HQ applies these CORS rules to /mobile/v1
only:
| Rule | Value |
|---|---|
| Origin match | Exact string match. No wildcards and no subdomain match. |
Access-Control-Allow-Methods | GET, POST, PUT, PATCH, DELETE, OPTIONS |
Access-Control-Allow-Headers | Authorization, Content-Type, If-Match, x-dash-relay-credential |
Access-Control-Expose-Headers | ETag, so that a script can read the conversation revision |
| Credentials | Never. Authentication uses a bearer header, not cookies. |
The HQ answers a preflight OPTIONS request before it checks authentication. Browsers never attach
a bearer to a preflight. The relay removes x-dash-relay-credential before the request gets to the
HQ. The header is in the list because the browser must get approval for it before it sends the
real request.
Error envelope
Errors use one JSON envelope, MobileApiError:
{
"code": "validation_failed",
"error": "Request body contains unknown fields",
"retryable": false
}| Field | Type | Description |
|---|---|---|
code | string | One of the codes below |
error | string | A message for people. Do not parse it. |
retryable | boolean | true if the same request can succeed later |
details | object | Optional. More data for some codes, for example current on revision_conflict |
| Code | Usual status | Meaning |
|---|---|---|
unauthorized | 401 | Missing or wrong mobile bearer. A speech provider credential failure also uses this code. |
not_found | 404, or 410 for a deleted conversation | The agent, conversation, sub-agent, definition, provider, or key does not exist |
validation_failed | 400, 403, 409, 422 | The request is not correct, or the resource is in a state that blocks it. Some routes add details.reason. |
revision_conflict | 409 | If-Match names an old revision |
conversation_busy | 409 | The conversation has an active turn |
gateway_offline | 500, 503 | 500: internal HQ error. 503: the HQ is about to stop. Both are retryable. |
delivery_choice_required | Only on the chat WebSocket. See Commands and durable acknowledgements. | |
rate_limited, capability_required | Reserved by the contract. No mobile route returns them now. | |
too_large, too_long, invalid, provider, network, unavailable | 413, 400, 502, 503 | Speech errors from the speech provider. See Speech. |
Two rules apply to each request that changes data (each method except GET, HEAD, and
OPTIONS):
| Status | Body | When |
|---|---|---|
503 | { "code": "gateway_offline", "error": "Gateway is shutting down", "retryable": true } | The HQ is about to stop |
409 | { "code": "validation_failed", "error": "Agent is pending deletion", "retryable": false } | The request targets an agent that the HQ is about to delete |
The three memory routes do not use the envelope. They return a bare { "error": "<message>" }
body, with no code and no retryable. Map their errors by HTTP status. See Memory.
Compatibility rules
- The HQ always sends a nullable field as an explicit JSON
null. A missing field and anullfield do not have the same meaning. The HQ can omit an optional field, for exampledeletedAt. - Cursors are opaque strings. Send them back without a change. Do not get an order from them.
- Agent IDs are opaque strings that are not empty. A user conversation ID is a UUID. A sub-agent
conversation ID is
sub_and then a 26-character ULID. All routes that take a conversation ID accept both forms. - The fixed request bodies (agents, conversations, sub-agent definitions, provider keys, speech configuration) reject unknown fields.
- Ignore unknown agent event types and properties, capability strings, and SSE event names. Do not reject the message that contains them.
Client capabilities
Some response shapes are new. Older clients reject keys and values that they do not know. Thus the HQ sends a new shape only to a client that says that it can read it. A client declares its capabilities in one of two places:
- On REST requests (to
/mobile/v1and to the loopback API): the headerX-Dashsquad-Capabilities, a comma-separated list. The HQ ignores values that it does not know. - On the chat WebSocket:
capabilitiesin thehelloframe. See HQ Chat WebSocket.
X-Dashsquad-Capabilities: long-horizon-v1, provider-recovery-v1, chat-archive-v1, chat-tools-v1Send a value only when GET /health lists the same value. An HQ that does not know
a value in hello closes the socket with 1002 invalid_frame. A capability changes what the HQ
sends to the client. It does not change what the client can do.
| Capability | Without it, the HQ |
|---|---|
long-horizon-v1 | Sends the origins agent, routine, wake and goal as notification, with no originDetail. Removes goal from conversations, and messaging and autonomy from agent configurations. Leaves out notices of kind goal, routine and wake. This applies only while long-horizon work is on. |
provider-recovery-v1 | Removes providerFailure from error frames and stored error events, and retryOf from accepted frames |
chat-archive-v1 | Removes archivedAt from conversations |
chat-tools-v1 | Sends the origin handoff as notification, with no originDetail. Leaves out notices of kind chat_renamed and chat_started. |
cancel-step-v1 and agent-rename-v1 are only on /health. A client does not send them.
Route index
Paths in this table and in the sections below are relative to /mobile/v1. For example,
GET /conversations means GET /mobile/v1/conversations.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /health | None | Health, API version, and capabilities |
GET | /identity | Bearer | The HQ's stable relay identity |
GET | /agents | Bearer | List agents |
POST | /agents | Bearer | Create an agent |
GET | /agents/:id | Bearer | Read one agent |
PUT | /agents/:id | Bearer | Rename an agent, or change its model or system prompt |
DELETE | /agents/:id | Bearer | Delete an agent |
POST | /agents/:id/enable | Bearer | Enable an agent |
POST | /agents/:id/disable | Bearer | Disable an agent |
GET | /agents/:id/skills | Bearer | List an agent's skills (read-only) |
GET | /agents/:id/memory | Bearer | List an agent's memories |
GET | /agents/:id/memory/:name | Bearer | Read one memory |
DELETE | /agents/:id/memory/:name | Bearer | Delete one memory |
GET | /agents/:id/subagent-types | Bearer | The sub-agent roster an agent can spawn |
GET | /agents/:id/subagent-definitions | Bearer | List the agent's own definition files |
GET | /agents/:id/subagent-definitions/:name | Bearer | Read one definition |
PUT | /agents/:id/subagent-definitions/:name | Bearer | Create or replace a definition |
DELETE | /agents/:id/subagent-definitions/:name | Bearer | Delete a definition |
GET | /models | Bearer | Models available for agent configuration |
GET | /conversations | Bearer | List conversations, newest first |
POST | /conversations | Bearer | Create a conversation (idempotent) |
GET | /conversations/:id | Bearer | Read a conversation. Also returns tombstones |
PATCH | /conversations/:id | Bearer + If-Match | Update the title or effort |
DELETE | /conversations/:id | Bearer + If-Match | Delete a conversation's content |
POST | /conversations/:id/archive | Bearer | Archive a conversation |
POST | /conversations/:id/restore | Bearer | Restore an archived conversation |
GET | /conversations/:id/messages | Bearer | Page through persisted messages |
GET | /conversations/:id/bootstrap | Bearer | Conversation, messages, and queue in one read |
GET | /conversations/:id/subagents | Bearer | A conversation's sub-agents |
POST | /subagents/:id/stop | Bearer | Cancel a sub-agent and its descendants |
POST | /subagents/:id/resume | Bearer | Send a message to a sub-agent |
GET | /agents/:agentId/conversations/:conversationId/events | Bearer | Replay a conversation's event log |
GET | /events | Bearer | Server-Sent Events stream of changes |
GET | /speech/config | Bearer | Read speech configuration |
PATCH | /speech/config | Bearer | Update speech configuration |
GET | /speech/models | Bearer | List speech models |
POST | /speech/transcriptions | Bearer | Transcribe a clip |
POST | /speech/speech | Bearer | Synthesize speech |
GET | /providers | Bearer | List AI providers and their keys (name, kind, when added, signed-in account) |
PUT | /providers/:id/keys/:name | Bearer | Add or replace a provider key |
DELETE | /providers/:id/keys/:name | Bearer | Remove a provider key |
POST | /providers/:id/sign-in | Bearer | Start a Claude or ChatGPT sign-in |
GET | /providers/:id/sign-in/:attemptId | Bearer | Read the status of a sign-in |
POST | /providers/:id/sign-in/:attemptId/code | Bearer | Finish a Claude sign-in with its code |
DELETE | /providers/:id/sign-in/:attemptId | Bearer | Cancel a sign-in |
POST | /ws-ticket | Bearer | Mint a single-use WebSocket ticket |
GET, PATCH | /agents/:id/autonomy | Bearer | Daily limit and unattended usage. See Long-horizon work. |
GET | /conversations/:id/goal | Bearer | The latest goal of a conversation |
POST | /conversations/:id/goal/commands | Bearer | One goal command |
GET | /agents/:id/goals | Bearer | The goals of an agent |
GET, POST | /agents/:id/routines | Bearer | List routines and wake-ups, or create a routine |
GET, PATCH, DELETE | /routines/:routineId | Bearer | Read, change, or delete one routine |
POST | /routines/:routineId/run | Bearer | Start one run now |
GET | /routines/:routineId/runs | Bearer | Run history |
GET | /agents/:id/threads | Bearer | Squad message threads of an agent |
GET | /threads/:threadId | Bearer | One thread and its messages |
POST | /threads/:threadId/pause, /resume, /close | Bearer | Pause, resume, or close a thread |
GET | /needs-you | Bearer | Items that wait for the user |
HQ health and identity
GET /health
Returns the HQ's health, the Mobile API version, the contract version, and the runtime capabilities. This is the only unauthenticated route. Call it first to check that the HQ speaks this API.
{
"status": "healthy",
"startedAt": "2026-07-12T00:00:00.000Z",
"pid": 4242,
"agents": 1,
"channels": 1,
"apiVersion": 1,
"contractVersion": 1,
"capabilities": [
"providers-v1",
"speech-v1",
"long-horizon-v1",
"provider-recovery-v1",
"chat-archive-v1",
"cancel-step-v1",
"agent-rename-v1",
"chat-tools-v1"
]
}| Field | Type | Description |
|---|---|---|
status | "healthy" | Always healthy when the HQ answers |
startedAt | string | ISO 8601 time the HQ process started |
pid | integer | The HQ's process ID |
agents | integer | Number of registered agents |
channels | integer | Number of registered channels |
apiVersion | 1 | Mobile API version |
contractVersion | 1 | The version of the mobile contract that the HQ speaks. An HQ that is older than this contract does not send this field. If the field is missing or is not 1, tell the user to update the HQ. Read this field before you check the rest of the body. |
capabilities | string[] | Features that depend on the HQ's runtime state. See below. |
| Capability | When present |
|---|---|
providers-v1 | When the HQ mounts the AI providers routes |
speech-v1 | Only while a speech provider can both transcribe and speak. It appears and disappears as speech credentials change. Gate every /speech/* call on it. |
long-horizon-v1 | While long-horizon work is on (the default). Gate the long-horizon routes on it. With it, a 404 on those routes means that the record does not exist. |
provider-recovery-v1 | Always on a current HQ. The HQ attributes provider failures and accepts retryOf. See HQ Chat WebSocket. |
chat-archive-v1 | Always on a current HQ. Gate archive and restore on it. |
cancel-step-v1 | Always on a current HQ. The socket accepts cancel_step. |
agent-rename-v1 | Always on a current HQ. PUT /agents/:id accepts name. |
chat-tools-v1 | Always on a current HQ. Agents rename their chats and start new chats. See Messages. |
Ignore capability strings that you do not know. A client that rejects an unknown capability fails when the HQ adds one. Some values also need the client to declare them. See Client capabilities.
GET /identity
Returns the HQ's stable identity: its relay gateway ID and its Ed25519 public key.
{
"gatewayId": "gateway-01",
"publicKey": "dash-test-public-key"
}| Field | Type | Description |
|---|---|---|
gatewayId | string | The HQ's gateway ID, the first DNS label of its relay hostname |
publicKey | string | The HQ's public key, base64 |
Errors: 401.
Agents
The agent routes return the stored record of the agent without its provider API keys. A mobile client can create an agent, rename it, and change its model and system prompt. Desktop manages all other settings.
The agent object:
| Field | Type | Description |
|---|---|---|
id | string | Opaque agent ID |
name | string | Display name |
config | object | The configuration of the agent. See below. |
status | string | registered, active, or disabled |
registeredAt | string | ISO 8601 registration time |
effortLevels | string[] | The effort levels that the current model supports, in order: low, medium, high, xhigh. Empty when the model does not think, or when no level reaches the provider. The HQ always sends it. An older HQ leaves it out: read a missing field as an empty list. |
config always has name, model (a provider/model string), and systemPrompt. It can also
have these optional blocks:
| Key | Contents |
|---|---|
fallbackModels, tools, mcpServers, plugins, providers | String arrays |
skills | paths, urls, learning (auto, on, or off), and minToolCalls |
workspace, maxTokens | Workspace path and output token limit |
swarm | The older sub-agent limits: enabled, maxConcurrentWorkers, maxWorkersPerRun, maxSteersPerWorker, maxRunSeconds, allowedModels |
subagents | enabled, delegation (auto or explicit), allowedTypes, allowedModels, modelAliases, maxConcurrent, maxPerTurn, maxRunSeconds, maxDepth |
memory | enabled and sweep (auto, on, or off). If the block is absent, memory is on with sweep auto. |
location | enabled. If the block is absent, location is on. |
messaging | send and receive (booleans): the agent can send messages to other agents, and can get messages from them. If the block is absent, both are on. Only with long-horizon-v1. |
autonomy | dailyLimitUsd and dailyLimitTokens, the daily limit of unattended work. If the block is absent, the limit is USD 5, or 3,000,000 tokens when the model has no price. Only with long-horizon-v1. Change it with PATCH /agents/:id/autonomy. |
The HQ never returns providerApiKeys. Only the loopback API sets memory, subagents,
skills.learning, and skills.minToolCalls. No API sets location. Show other configuration keys as information only.
The agent routes use the envelope. A 404 is
{ "code": "not_found", "error": "Agent not found", "retryable": false }. An unexpected failure is
500 with code: "gateway_offline" and retryable: true.
GET /agents
Lists all registered agents.
[
{
"id": "agent-01",
"name": "Mobile Helper",
"config": {
"name": "Mobile Helper",
"model": "anthropic/claude-sonnet-4-5",
"systemPrompt": "Help from anywhere.",
"fallbackModels": ["openai/gpt-5-mini"],
"tools": ["read_file"],
"skills": {
"paths": ["skills/mobile"],
"urls": ["https://skills.dash.example/mobile.md"]
},
"workspace": "/tmp/dash-mobile-helper",
"maxTokens": 4096,
"mcpServers": ["linear"],
"plugins": ["dash-core-providers"],
"providers": ["anthropic", "openai"],
"subagents": {
"enabled": true,
"maxConcurrent": 2,
"maxDepth": 1
}
},
"status": "active",
"registeredAt": "2026-07-12T00:00:00.000Z",
"effortLevels": ["low", "medium", "high"]
}
]Errors: 401.
POST /agents
Creates an agent. The body accepts exactly these three fields. All three are necessary:
{
"name": "Mobile Helper",
"model": "anthropic/claude-sonnet-4-5",
"systemPrompt": "Help from anywhere."
}Returns 201 with the new agent object.
| Status | Code | When |
|---|---|---|
400 | validation_failed | The body is not valid JSON or not an object, a field is missing or not valid, or the body has another field (Request body contains unknown fields) |
401 | unauthorized | |
409 | validation_failed | Another agent has this name. details is { "field": "name", "reason": "taken" }. The HQ compares names without case. |
500 | gateway_offline | Unexpected failure |
GET /agents/:id
Returns one agent object.
Errors: 401, 404.
PUT /agents/:id
Changes an agent. Send one or more of name, model, and systemPrompt. While /health lists
long-horizon-v1, the HQ also accepts messaging (send, receive). A switch that you omit keeps
its value. The HQ accepts no other field.
{
"name": "Travel Helper",
"model": "openai/gpt-5-mini",
"systemPrompt": "Help concisely from anywhere."
}Returns 200 with the changed agent object. After a model change, effortLevels shows the levels of the new model.
name renames the agent, and the agent keeps its ID. The HQ removes spaces at the start and end of
the name. A name has 1 to 64 characters, no line break and no control character. It must be
unique, and the HQ compares names without case. If the HQ refuses the name, it changes no other
field.
After a rename, the newest chat of the agent gets an agent_renamed notice, if the agent has a chat.
| Status | Code | When |
|---|---|---|
400 | validation_failed | Not valid JSON, an empty object, an unknown field, or a value that is not valid |
400 | validation_failed | A name that is not valid. details.field is name, and details.reason is empty, too_long (with details.maxLength: 64), or invalid |
401 | unauthorized | |
404 | not_found | No such agent |
409 | validation_failed | The HQ is about to delete the agent |
409 | validation_failed | Another agent has this name. details is { "field": "name", "reason": "taken" } |
500 | gateway_offline | Unexpected failure |
DELETE /agents/:id
Deletes an agent. The HQ cancels its active work and archives its conversations. Each
conversation goes to status: "archived", and the HQ sends a conversation:changed event for it.
You can still read an archived conversation. You cannot change it, delete it, or start a new turn
in it.
{ "ok": true }If the delete does not complete, you can send the same request again. When the HQ starts, it also finishes deletes that did not complete.
Errors: 401, 404, 500.
POST /agents/:id/enable
POST /agents/:id/disable
These routes enable or disable an agent. Both take no body and return { "ok": true }.
Errors: 401, 404, 409 (enable of an agent that the HQ is about to delete), 500.
GET /agents/:id/skills
A read-only list of the skills that the agent can load. The list also contains the skills that the agent learned by itself. A mobile client cannot create, install, edit, or remove skills.
[
{
"name": "write-files",
"description": "Use when writing files in this project",
"source": "agent",
"content": "---\nname: write-files\ndescription: Use when writing files in this project\n---\nThese lessons were learned automatically from previous sessions.\n\n## Lessons\n\n- Always use printf instead of echo when writing files.\n"
},
{
"name": "deep-research",
"description": "Use for multi-source research tasks",
"trigger": "research",
"source": "plugin"
}
]| Field | Type | Description |
|---|---|---|
name | string | Skill name |
description | string | When to use the skill |
trigger | string | Optional trigger keyword |
source | string | managed, agent, remote, or plugin |
content | string | Optional. The full text of the skill. |
Errors: 401, 404 (not_found, Agent not found).
Memory
These routes show the saved memories of an agent. A mobile client can list, read, and delete memories. Only Desktop can write memories and change memory settings.
Map memory errors by HTTP status. On each failure, these three routes return a bare
{ "error": "<message>" } body, not the MobileApiError envelope. A 404 means that the agent or
the memory does not exist.
GET /agents/:id/memory
Lists the memories of the agent. If the agent has no memory directory, the list is empty.
[
{
"name": "user-timezone",
"description": "Gerry is in Singapore (UTC+8)",
"type": "user",
"source": "agent",
"createdAt": "2026-09-05",
"updatedAt": "2026-09-05",
"size": 24
},
{
"name": "repo-pnpm",
"description": "The repo uses pnpm",
"type": "project",
"source": "sweep",
"createdAt": "2026-09-05",
"updatedAt": "2026-09-05",
"size": 18
}
]| Field | Type | Description |
|---|---|---|
name | string | Memory name |
description | string | One-line summary |
type | string | user, feedback, project, or reference |
source | string | agent, sweep, user, or import |
createdAt, updatedAt | string | Calendar dates (YYYY-MM-DD), not timestamps |
size | integer | Content size |
Errors: 401, 404 (unknown agent).
GET /agents/:id/memory/:name
Returns one memory. The record has the same fields as a list entry, but it has content (the full
text) in place of size.
Errors: 401, 404 (unknown agent or memory).
DELETE /agents/:id/memory/:name
Deletes one memory and returns its name:
{ "name": "user-timezone" }| Status | When |
|---|---|
401 | Missing or wrong bearer (envelope body) |
404 | Unknown agent or memory |
400 | The memory store rejected the name |
503 | Memory is off or not configured for this agent |
500 | Unexpected failure |
Sub-agent definitions
These routes show the sub-agent types that an agent can start, and the definition files that the
agent owns. They use the MobileApiError envelope. A missing agent or
definition is 404 not_found. The HQ adds these routes only when it has a sub-agent definition
registry. Without the registry, they return 404.
GET /agents/:id/subagent-types
Returns the full list of sub-agent types that this agent can start. The HQ merges the built-in
types, plugin definitions, the definition directory of the agent, and the workspace directories, by
precedence. Then it removes the types that are not in subagents.allowedTypes of the agent.
If two definitions have the same name, the definition that lost comes directly after the winner,
with shadowedBy set. The list does not contain the full definition text. Get it from the
definition route.
| Field | Type | Description |
|---|---|---|
types | object[] | The types. See below. |
unknownAllowedTypes | string[] | Entries in subagents.allowedTypes that match no type. This is not an error. But if all entries are here, the agent can start no sub-agent, so show this to the user. |
Each type has name, description, and source (builtin, workspace, agent, or plugin).
It can also have location, tools, disallowedTools, model, skills, maxTurns,
background, isolation ("worktree"), and shadowedBy. shadowedBy is the file path of the
winner, or its source if it has no file.
Errors: 401, 404.
GET /agents/:id/subagent-definitions
Lists the .md files in the definition directory of the agent, sorted by file name. You can write
only this layer. Workspace and plugin definitions are read-only, and only the type list shows them.
If the directory does not exist, the list is empty.
{
"definitions": [
{ "name": "code-reviewer", "file": "code-reviewer.md" }
]
}Errors: 401, 404.
GET /agents/:id/subagent-definitions/:name
Returns the raw Markdown of a definition, byte for byte:
{ "name": "code-reviewer", "raw": "---\nname: code-reviewer\n..." }:name is the file name without .md. It must match ^[a-z0-9][a-z0-9-]*$.
| Status | Code | When |
|---|---|---|
400 | validation_failed | :name does not match the pattern |
401 | unauthorized | |
404 | not_found | No such agent or definition |
PUT /agents/:id/subagent-definitions/:name
Creates or replaces <name>.md in the definition directory of the agent.
{ "raw": "---\nname: code-reviewer\ndescription: Review a diff\n---\nYou review diffs." }| Field | Type | Description |
|---|---|---|
raw | string | Necessary. The full Markdown with its YAML frontmatter. It must not be blank and must be 262,144 bytes (UTF-8) or less. |
The HQ parses the body before it writes the file. The frontmatter name must be equal to :name.
If the write is successful, the HQ refreshes the type list of the agent and returns:
{ "ok": true, "name": "code-reviewer" }| Status | Code | When |
|---|---|---|
400 | validation_failed | Bad :name, JSON that is not valid, a missing or blank raw, or a raw that is too large |
401 | unauthorized | |
404 | not_found | No such agent |
422 | validation_failed | raw is not a valid definition, or its frontmatter name is not equal to :name |
DELETE /agents/:id/subagent-definitions/:name
Deletes the definition file and refreshes the type list. Returns
{ "ok": true, "name": "<name>" }.
Errors: 400 (bad :name), 401, 404 (no such agent or definition).
Models
GET /models
Lists the models that you can set on an agent. The list comes from the model cache of the HQ. When the cache is old, the HQ gets a new list from the providers.
{
"models": [
{
"value": "anthropic/claude-sonnet-4-5",
"label": "Claude Sonnet 4.5",
"provider": "anthropic"
},
{
"value": "openai/gpt-5-mini",
"label": "GPT-5 mini",
"provider": "openai"
}
],
"source": "live",
"errors": {},
"fetchedAt": "2026-07-12T00:00:00.000Z",
"supportedModelsReviewedAt": "2026-07-05"
}| Field | Type | Description |
|---|---|---|
models | object[] | value (a provider/model string for the model of an agent), label, and provider |
source | string | live if the list came from the providers, bootstrap if it came from the bundled catalogs |
errors | object | Map of provider to error message, for providers that did not return a list |
fetchedAt | string | ISO 8601 time of the last fetch |
supportedModelsReviewedAt | string | The date of the last review of the bundled catalogs, or unreviewed |
This route takes no query parameters. A query string returns 400 validation_failed
(Unknown models query parameter).
Errors: 400, 401.
Conversations
A conversation belongs to one agent. The HQ stores its metadata, its messages, and an append-only event log, and every paired client sees the same state. Live turns stream over HQ Chat WebSocket. These routes read and manage the stored state.
The conversation object
{
"id": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
"agentId": "agent-01",
"agentName": "Mobile Helper",
"title": "Mobile launch check",
"revision": 2,
"status": "idle",
"activeTurnId": null,
"lastSeq": 7,
"lastMessagePreview": "Ready from the gateway.",
"createdAt": "2026-07-12T00:00:00.000Z",
"updatedAt": "2026-07-12T00:00:05.000Z",
"kind": "user",
"queuePaused": false,
"queueRevision": 0,
"pendingFollowUpCount": 0,
"effort": null
}| Field | Type | Description |
|---|---|---|
id | string | UUID, or sub_<ulid> for a sub-agent conversation |
agentId | string | The agent that owns the conversation |
agentName | string | The agent's name when the conversation was created |
title | string | Title. New Conversation until one is set. |
revision | integer | Starts at 1 and increases on every change. Used for ETag and If-Match. |
status | string | idle, running, interrupted, archived, or deleted |
activeTurnId | string or null | The active turn, or null |
lastSeq | integer | Sequence number of the latest replayable chat frame. See Replay. |
lastMessagePreview | string or null | Short preview of the latest message |
createdAt, updatedAt | string | ISO 8601 times |
queuePaused | boolean | true while the queue of Follow Up messages is paused |
queueRevision | integer | Revision of the queue. It increases on every queue change. |
pendingFollowUpCount | integer | Follow Up messages that wait to run |
effort | string or null | The effort for this conversation: low, medium, high, or xhigh. null is Default. The HQ always sends it. An older HQ leaves it out: read a missing field as null. |
deletedAt | string | Present only on a deleted conversation |
archivedAt | string or null | Only with chat-archive-v1. When the user archived the conversation, or null. An agent delete archives the conversation but does not set this field. |
goal | object or null | Only with long-horizon-v1. The latest goal of the conversation, in short form: id, status, optional activity, objective, createdBy, iteration, checksVerified, checksTotal, updatedAt. See Goals. A missing field means null. |
kind | string | user, or subagent for a sub-agent's own conversation |
parentConversationId, parentTurnId, subagent | Present only when kind is subagent. subagent describes the child. See Sub-agents. |
A client compares title against New Conversation to tell whether a conversation is still
untitled.
ETag and If-Match
POST, GET, PATCH, and DELETE on a single conversation return its revision as a quoted
ETag:
ETag: "2"PATCH and DELETE require an If-Match header with the revision you last saw, in the same quoted
form. The header must match ^"(0|[1-9][0-9]*)"$. A missing or malformed header is 400
validation_failed. If the revision is stale, the HQ returns 409 revision_conflict with the
current conversation in details.current, so you can merge and retry without another read:
{
"code": "revision_conflict",
"error": "Conversation revision 1 is stale",
"retryable": false,
"details": {
"current": {
"id": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
"agentId": "agent-01",
"agentName": "Mobile Helper",
"title": "Mobile launch check",
"revision": 2,
"status": "idle",
"activeTurnId": null,
"lastMessagePreview": "Ready from the gateway.",
"createdAt": "2026-07-12T00:00:00.000Z",
"updatedAt": "2026-07-12T00:00:05.000Z",
"kind": "user",
"queuePaused": false,
"queueRevision": 0,
"pendingFollowUpCount": 0,
"lastSeq": 7,
"effort": null
}
}
}Delete and archive
When you delete a conversation, the HQ removes its messages, event log, pending inputs, and
queued notifications. The HQ keeps the row as a tombstone with status: "deleted", a new
revision, and deletedAt. The HQ also makes a tombstone of each sub-agent conversation below
it, even an active one.
GET /conversations/:id still returns the tombstone. A new PATCH or DELETE returns 410 with
code not_found and retryable: false.
The HQ archives a conversation in two cases: when you archive it with
POST /conversations/:id/archive, and when you delete its agent.
The conversation goes to status: "archived", and you can still read it. PATCH and DELETE
return 409 validation_failed, and the HQ refuses new turns. The list routes still return
archived conversations, with status and archivedAt. A routine run never posts into an archived
conversation.
To tell the two cases apart, read archivedAt. It is set only when the user archived the
conversation. You can restore only such a conversation.
POST /conversations/:id/archive
Archives one conversation. Needs chat-archive-v1 in /health. Takes no body and no If-Match.
Returns 200 with the conversation object: status: "archived" and archivedAt set. The HQ sends
conversation:changed on /events when the conversation changes.
The route is idempotent. If the user archived the conversation before, the HQ returns it again.
| Status | Code | When |
|---|---|---|
401 | unauthorized | |
404 | not_found | No such conversation |
409 | validation_failed | The HQ cannot archive the conversation now. details.reason tells why. See below. |
details.reason | Meaning |
|---|---|
running | A turn is active. details.activeTurnId names it. Stop the turn first. |
goal | The conversation has a goal that is active or waiting. Pause or clear the goal first. A paused goal does not block the archive. |
wake_up | A wake-up of the agent waits to run in this conversation. Cancel the wake-up first. |
agent_removed | The HQ archived the conversation when it deleted the agent |
POST /conversations/:id/restore
Restores a conversation that the user archived. Needs chat-archive-v1 in /health. Takes no
body. Returns 200 with the conversation object and archivedAt: null. The HQ sends
conversation:changed when the conversation changes.
The route is idempotent. A conversation that is not archived comes back with no change.
| Status | Code | When |
|---|---|---|
401 | unauthorized | |
404 | not_found | No such conversation |
409 | validation_failed | details.reason is agent_removed. The HQ archived the conversation when it deleted the agent. |
Query parameter rules
Three read routes have strict query rules: GET /conversations, /messages, and /bootstrap.
Each of them returns 400 validation_failed for these errors:
- A query parameter that the route does not define
- A parameter that is in the query more than one time
- An empty value
- A
limitthat is not a positive integer, or that is more than the maximum of the route
A cursor that the HQ cannot decode is 400 Invalid pagination cursor.
GET /conversations
Lists conversations, most recently updated first. Deleted conversations and sub-agent conversations are left out. Archived conversations are included.
| Query parameter | Type | Description |
|---|---|---|
agentId | string | Optional. Only this agent's conversations. |
limit | integer | Optional. 1 to 100, default 50. |
cursor | string | Optional. nextCursor from the previous page. |
{
"items": [
{
"id": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
"agentId": "agent-01",
"agentName": "Mobile Helper",
"title": "Mobile launch check",
"revision": 2,
"status": "idle",
"activeTurnId": null,
"lastMessagePreview": "Ready from the gateway.",
"createdAt": "2026-07-12T00:00:00.000Z",
"updatedAt": "2026-07-12T00:00:05.000Z",
"kind": "user",
"queuePaused": false,
"queueRevision": 0,
"pendingFollowUpCount": 0,
"lastSeq": 7,
"effort": null
}
],
"nextCursor": "opaque:conversation:cursor:1"
}nextCursor is null on the last page.
Errors: 400, 401.
POST /conversations
Creates a conversation. requestId makes the call idempotent. If you send a request again with the
same requestId, the HQ returns the conversation that the first request made. It does not make a
new one.
{
"agentId": "agent-01",
"requestId": "request-01",
"title": "Mobile launch check"
}| Field | Type | Description |
|---|---|---|
agentId | string | Required. The agent that owns the conversation. |
requestId | string | Required. A client-generated key, unique per intended conversation. |
title | string | Optional. Defaults to New Conversation. |
Every string must not be blank. The HQ trims each string. The route returns 201 with the
conversation object and its ETag. It also returns 201 when an earlier request with the same
requestId made the conversation. A
conversation:changed event is sent on /events.
| Status | Code | When |
|---|---|---|
400 | validation_failed | Invalid JSON, not an object, an unknown field, or a blank or non-string value |
401 | unauthorized | |
404 | not_found | No agent with that agentId |
GET /conversations/:id
Returns the conversation object and its ETag. Deleted conversations are returned as tombstones.
Sub-agent conversations can be read here by their sub_ ID.
Errors: 401, 404.
PATCH /conversations/:id
Updates the title or the effort. Requires If-Match. Send one key or both.
{
"title": "Mobile launch verified",
"effort": "high"
}| Field | Type | Description |
|---|---|---|
title | string | Optional. Non-blank, trimmed. |
effort | string or null | Optional. One of low, medium, high, or xhigh, or null to go back to Default. The HQ does not check the model. It applies the level from the next turn. |
Returns the updated conversation and its new ETag, and sends conversation:changed. You can change the effort while a turn runs. The turn in progress keeps its level.
| Status | Code | When |
|---|---|---|
400 | validation_failed | Missing or malformed If-Match, invalid JSON, an empty object, an unknown field, a blank title, or an unknown effort value |
401 | unauthorized | |
404 | not_found | No such conversation |
409 | validation_failed | The conversation is archived |
409 | revision_conflict | If-Match is stale. details.current holds the current conversation. |
410 | not_found | The conversation was deleted |
DELETE /conversations/:id
Deletes the conversation's content and returns the tombstone with its new ETag. Requires
If-Match. Sends conversation:deleted on /events.
The tombstone is the conversation object with status: "deleted", activeTurnId: null, a
revision one higher than before, and deletedAt set.
The HQ checks these conditions in order and returns the first that applies:
| Status | Code | When |
|---|---|---|
400 | validation_failed | Missing or malformed If-Match |
404 | not_found | No such conversation |
410 | not_found | Already deleted |
409 | validation_failed | The conversation is archived |
409 | conversation_busy | A turn is active. details.activeTurnId names it. Stop the turn, then try again. |
409 | revision_conflict | If-Match is stale |
A 401 is returned before any of these when the bearer is wrong.
GET /conversations/:id/messages
Pages through the stored messages of the conversation. The first page has the newest messages. The
items in a page are in time order, from the lowest ordinal to the highest. Pass nextCursor as before to get the page of older
messages.
| Query parameter | Type | Description |
|---|---|---|
limit | integer | Optional. 1 to 200, default 100. |
before | string | Optional. nextCursor from the previous page. |
{
"items": [
{
"id": "018f0f4a-5c42-7a8b-9c01-3234567890ab",
"conversationId": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
"turnId": "018f0f4a-5c42-7a8b-9c01-2234567890ab",
"ordinal": 1,
"role": "user",
"status": "completed",
"content": {
"type": "user",
"text": "Is the mobile connection ready?"
},
"createdAt": "2026-07-12T00:00:01.000Z",
"updatedAt": "2026-07-12T00:00:01.000Z",
"runId": "018f0f4a-5c42-7a8b-9c01-2234567890ab",
"segmentIndex": 0,
"deliveryKind": "normal",
"origin": "user"
},
{
"id": "018f0f4a-5c42-7a8b-9c01-4234567890ab",
"conversationId": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
"turnId": "018f0f4a-5c42-7a8b-9c01-2234567890ab",
"ordinal": 2,
"role": "assistant",
"status": "completed",
"content": {
"type": "assistant",
"events": [
{
"type": "text_delta",
"text": "Ready from the gateway."
}
]
},
"createdAt": "2026-07-12T00:00:02.000Z",
"updatedAt": "2026-07-12T00:00:05.000Z",
"runId": "018f0f4a-5c42-7a8b-9c01-2234567890ab",
"segmentIndex": 0,
"deliveryKind": "normal",
"origin": "user"
}
],
"nextCursor": null,
"throughSeq": 5
}| Field | Type | Description |
|---|---|---|
items[].role | string | user or assistant |
items[].status | string | accepted, streaming, completed, cancelled, failed, or interrupted |
items[].content | object | One of the three content shapes below |
items[].runId | string | The run that the message belongs to. A run can span several turns when queued messages are delivered into it. |
items[].segmentIndex | integer | Position of the message's segment within its run, from 0 |
items[].deliveryKind | string | normal, steer (sent into an active turn), or follow_up (queued until the turn ended) |
items[].deliveryStatus | string | Optional. pending, delivered, or not_delivered, for a steer or Follow Up message |
items[].origin | string | Optional. Who caused the turn. See the origin table below. |
items[].originDetail | object | Optional. More data about the origin. kind is the same as origin. Absent for user, notification and parent. |
nextCursor | string or null | Cursor for older messages, or null when there are none |
throughSeq | integer | The conversation's lastSeq at read time |
Status rules for a client:
- A
userrow shows the delivery of its prompt, not the result of the run. When the run ends, each delivereduserrow of the run changes fromacceptedtocompleted, also when the run fails or is cancelled. - The
assistantrow of the run shows the result of the run:completed,cancelled,failed, orinterrupted. - If the HQ does not deliver a Steer message, its
userrow changes tofailed, withdeliveryStatus: "not_delivered".
Message origins:
origin | Meaning | originDetail |
|---|---|---|
user | The user sent the message | |
notification | The HQ started the turn, for example when a background sub-agent sends its report | |
parent | A turn in a sub-agent conversation that the parent agent started | |
agent | A message from another squad member. Needs long-horizon-v1. | fromAgentId, fromAgentName, threadId, messageId |
routine | A scheduled routine run. Needs long-horizon-v1. | routineId, runId, title, scheduledFor |
wake | A wake-up that the agent set for itself. Needs long-horizon-v1. | routineId, note |
goal | The next iteration of a conversation goal. Needs long-horizon-v1. | goalId, iteration (from 1) |
handoff | The first turn of a chat that an agent started from another chat. Needs chat-tools-v1. | fromConversationId, fromConversationTitle |
Only user is the person. No other origin answers a question card or runs a command. Show a
message with an origin that you do not know as a system row, not as a user message.
Message content:
content.type | Fields | Description |
|---|---|---|
user | text, optional images | What the user sent. Each image has mediaType (image/jpeg, image/png, image/gif, or image/webp) and base64 data. |
assistant | events | The agent's events for the turn, rebuilt from the event log. Ignore event types that you do not know. With provider-recovery-v1, a stored error event also has providerFailure. See HQ Chat WebSocket. |
notice | kind, text, optional conversationId | A standalone notice, such as Learned: write-files. text is ready to show. Notices use the assistant role. |
Notice kinds:
kind | Meaning |
|---|---|
skill_learned, memory_saved | The agent learned a skill or saved a memory after a turn |
goal, routine, wake | A goal event, a routine run, or a wake-up note. Needs long-horizon-v1. |
agent_renamed | Someone renamed an agent: the user, the agent itself, or another agent. The HQ sends this kind to every client. |
chat_renamed | An agent renamed this chat. Needs chat-tools-v1. |
chat_started | An agent started a new chat from this chat. conversationId names the new chat. Needs chat-tools-v1. |
Errors: 400, 401, 404. A deleted conversation returns 404 here.
GET /conversations/:id/bootstrap
Returns all the data that a client needs to open a conversation. The HQ reads it in one
transaction. The data is the conversation, a page of messages, the queued inputs, and the replay
position. Takes the same query
parameters as /messages.
{
"conversation": {
"id": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
"agentId": "agent-01",
"agentName": "Mobile Helper",
"title": "Mobile launch check",
"revision": 3,
"status": "running",
"activeTurnId": "018f0f4a-5c42-7a8b-9c01-2234567890ab",
"lastMessagePreview": "Ready from the gateway.",
"createdAt": "2026-07-12T00:00:00.000Z",
"updatedAt": "2026-07-12T00:00:05.000Z",
"kind": "user",
"queuePaused": false,
"queueRevision": 4,
"pendingFollowUpCount": 1,
"lastSeq": 7,
"effort": "high"
},
"messages": [],
"nextCursor": null,
"pendingInputs": [
{
"inputId": "818f0f4a-5c42-7a8b-9c01-1234567890ab",
"kind": "follow_up",
"text": "Also compare the migration cost.",
"state": "queued",
"revision": 1,
"enqueueOrder": 1,
"createdAt": "2026-09-15T05:00:00.000Z",
"updatedAt": "2026-09-15T05:00:00.000Z"
}
],
"queuePaused": false,
"queueRevision": 4,
"throughSeq": 7
}| Field | Type | Description |
|---|---|---|
conversation | object | The conversation object |
messages, nextCursor | The newest page of messages, the same as /messages returns | |
pendingInputs | object[] | Queued Steer messages, and Follow Up messages that are queued or in delivery, in queue order |
pendingInputs[].kind | string | steer or follow_up |
pendingInputs[].state | string | queued, delivering, delivered, removed, or failed |
pendingInputs[].revision | integer | Increases on each edit. Chat commands that edit or remove an input must name it. |
pendingInputs[].images | object[] | Optional. Same shape as message images. |
queuePaused, queueRevision | The queue state at read time | |
throughSeq | integer | The replay position at read time. Subscribe on the chat WebSocket from here. |
Inputs are added, edited, and removed with chat commands on the WebSocket. See HQ Chat WebSocket.
Errors: 400, 401, 404.
Sub-agents
A sub-agent is a child agent that the agent of a conversation starts. Each child has its own
conversation, with kind: "subagent" and an ID of the form sub_<ulid>. The routes below use that
ID as the sub-agent ID. These routes use the MobileApiError envelope. The HQ adds them only when it runs the sub-agent coordinator. Without it, they return
404.
The subagent field of a sub-agent conversation describes the child:
{
"type": "code-reviewer",
"name": "reviewer",
"status": "running",
"description": "Review the diff",
"prompt": "Review the diff and report findings",
"model": "anthropic/claude-opus-4",
"background": true,
"isolation": "worktree",
"depth": 1,
"startedAt": "2026-07-12T00:01:00.000Z",
"usage": {
"inputTokens": 1204,
"outputTokens": 318
},
"toolCallCount": 7,
"oneShot": false
}status is one of running, waiting_input, done, failed, cancelled, interrupted, or
max_turns. The last five are terminal. After the HQ starts again, it sets each child that did not
finish to interrupted. The object can also have endedAt, report, and workspace.
workspace is the directory where the child ran.
GET /conversations/:id/subagents
Lists the sub-agents of a conversation, oldest first. Each entry has the data for a tasks panel, so
you do not have to open each child. The HQ scans report before it sends it, because clients show
it directly. The scan makes control tags safe and escapes role prefixes.
{
"subagents": [
{
"id": "sub_01JQ8Z3K7M2N4P6R8T0V2W4X6Y",
"name": "scout",
"type": "Explore",
"description": "Survey the repo",
"status": "done",
"background": false,
"depth": 1,
"startedAt": "2026-09-06T09:00:00.000Z",
"endedAt": "2026-09-06T09:02:31.000Z",
"usage": { "inputTokens": 4821, "outputTokens": 903 },
"toolCallCount": 12,
"report": "The auth flow lives in apps/hq/src/auth.ts.",
"oneShot": true
},
{
"id": "sub_01JQ8Z3K7M2N4P6R8T0V2W4X71",
"name": "reviewer",
"type": "general-purpose",
"description": "Review the diff",
"status": "running",
"background": true,
"depth": 2,
"startedAt": "2026-09-06T09:05:00.000Z",
"toolCallCount": 0,
"oneShot": false
}
]
}| Field | Type | Description |
|---|---|---|
id | string | The conversation ID of the child |
name | string | Optional display name |
type | string | The sub-agent type of the child |
description | string | Short task description |
status | string | See above |
background | boolean | true if the child runs in the background |
depth | integer | Nesting depth. 1 for a child of a user conversation. |
startedAt, endedAt | string | ISO 8601 times. endedAt is absent while the child is active. |
usage | object | Optional. inputTokens and outputTokens. |
toolCallCount | integer | Tool calls until now |
report | string | The scanned output of the child. Absent until the child gets to a terminal status. |
oneShot | boolean | true for types that you cannot resume, for example Explore and Plan |
The list does not contain prompt, model, isolation, or workspace. To get them, read the
child conversation. The route returns a maximum of the 100 newest children.
Errors: 401, 404 (no such conversation).
POST /subagents/:id/stop
Cancels the sub-agent and each descendant below it. The HQ stops the deepest children first. Takes no body.
{ "ok": true, "status": "cancelled" }status is the terminal status of the child after the stop. It is not always cancelled, because
the child can finish by itself during the stop.
| Status | Code | When |
|---|---|---|
401 | unauthorized | |
404 | not_found | No such sub-agent. A user conversation ID also gets 404. |
409 | validation_failed | The sub-agent is already in a terminal status. The message names that status. |
POST /subagents/:id/resume
Sends a message to the sub-agent from its parent. This has the same effect as the send_message
tool of the agent. An active child puts the message in its queue as its next turn. If the child
finished, the HQ resumes it on its own conversation. This also applies to a child that an HQ
restart interrupted.
{ "message": "Also check the migrations.", "requestId": "req-42" }| Field | Type | Description |
|---|---|---|
message | string | Necessary. Not blank. |
requestId | string | Optional. 1 to 256 characters, not blank. The HQ sends it back as requestId on the accepted chat frame of the turn that this message starts. An answer to a question from the child does not start a turn, so it gets no requestId back. |
{ "ok": true, "status": "running", "mode": "queued" }| Field | Type | Description |
|---|---|---|
ok | boolean | true if the HQ delivered the message |
status | string | The status of the child: spawning, or a sub-agent status |
mode | string | queued if the child was active, resumed if the HQ started a new turn |
The requestId here is not the idempotency key of POST /conversations. The HQ does not store it,
and it does not prevent duplicates. If you send the same request again, the HQ starts a second turn.
| Status | Code | When |
|---|---|---|
400 | validation_failed | JSON that is not valid, a blank message, or a requestId that is not valid |
401 | unauthorized | |
404 | not_found | No such sub-agent |
409 | validation_failed | A one-shot type, a child at its message limit, a child whose permissions the HQ cannot build again, or a child with no parent |
Replay and events
GET /agents/:agentId/conversations/:conversationId/events
Returns the conversation's replayable chat frames with seq greater than sinceSeq, in
order. Use it to catch up after the chat connection stops. Start from the last seq that you
applied, or from throughSeq on a bootstrap.
| Query parameter | Type | Description |
|---|---|---|
sinceSeq | integer | Required. Non-negative. 0 returns every frame. |
{
"frames": [
{
"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": 6,
"userMessageId": "018f0f4a-5c42-7a8b-9c01-3234567890ab",
"assistantMessageId": "018f0f4a-5c42-7a8b-9c01-4234567890ab",
"revision": 2
}
],
"throughSeq": 7
}Each frame has the same shape as a sequenced frame on the chat WebSocket. The frames are accepted, event,
done, error, input_accepted, input_updated, input_removed, input_delivered,
input_failed, queue_paused, or queue_resumed. See
HQ Chat WebSocket. Replayed accepted frames leave out
origin, kind, and requestId. Read the durable origin and kind from the message and the
conversation instead.
| Status | Code | When |
|---|---|---|
400 | validation_failed | A missing sinceSeq, any other query parameter, a repeated parameter, or a value that is not a non-negative integer |
401 | unauthorized | |
404 | not_found | No such conversation, or the conversation belongs to a different agent |
GET /events
A Server-Sent Events stream that tells clients when to refresh. Keep it open and re-read a conversation when its revision is newer than the one you hold.
GET /mobile/v1/events HTTP/1.1
Authorization: Bearer <mobile-token>
Accept: text/event-streamThe response has Content-Type: text/event-stream and Cache-Control: no-cache. Each event has an
event: name equal to the payload's type, and a JSON data: line:
event: conversation:changed
data: {"type":"conversation:changed","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","revision":2}
event: conversation:deleted
data: {"type":"conversation:deleted","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab","revision":3}| Event | Sent when |
|---|---|
conversation:changed | A conversation was created, updated, retitled automatically, archived, given a notice, or changed as a turn ran. revision is the new revision. |
conversation:deleted | A conversation was deleted. revision is the tombstone's revision. |
The same stream also has the other change events of the HQ, for example agent, channel, plugin, and swarm changes. Ignore each event name that you do not use.
The HQ sends a : keepalive comment line each 30 seconds. Events have no id: field, so you
cannot resume the stream with Last-Event-ID. After you connect again, get the conversation list
again and compare revisions.
Through the relay, an open stream counts against the HQ's concurrent-stream cap. See Relay API.
Errors: 401.
AI providers
These routes let a phone or the web app manage the AI provider keys of the HQ. Before you call
them, look for the providers-v1 capability in
GET /health.
The routes are the relay-reachable form of the
/credentials routes that only Desktop uses, limited to provider
API keys. They use the same credential slots, so Desktop shows a key that you write here.
| Method | Path | Purpose |
|---|---|---|
GET | /providers | All providers that the HQ has a catalog for, with the name and kind of each key. Never a key value. |
PUT | /providers/:id/keys/:name | Add or replace one key. Body: { "value": "<api key>" }. Returns { "ok": true }. |
DELETE | /providers/:id/keys/:name | Remove one key and the OAuth sign-in data that the HQ stored with it. Returns { "ok": true }. |
POST | /providers/:id/sign-in | Start a subscription sign-in. Body: { "keyName": "<name>" }. Returns the sign-in. |
GET | /providers/:id/sign-in/:attemptId | Read a sign-in. Its status is pending, complete, failed, cancelled, or expired. |
POST | /providers/:id/sign-in/:attemptId/code | Finish a code sign-in. Body: { "code": "<code from the provider>" }. Returns the sign-in. |
DELETE | /providers/:id/sign-in/:attemptId | Cancel a pending sign-in. Returns { "ok": true }. |
GET /providers
{
"providers": [
{
"id": "anthropic",
"label": "Anthropic",
"pluginName": "dash-core-providers",
"description": "Claude models from Anthropic.",
"keyPlaceholder": "sk-ant-...",
"keyConsoleUrl": "https://console.anthropic.com/settings/keys",
"docsUrl": "https://docs.anthropic.com",
"keys": [
{
"name": "claude-login",
"kind": "oauth",
"addedAt": "2026-09-26T03:00:00.000Z",
"account": { "email": "user@example.com", "plan": null }
},
{
"name": "default",
"kind": "api_key",
"addedAt": "2026-09-20T08:30:00.000Z",
"account": null
}
],
"signIn": { "kind": "code", "label": "Sign in with Claude" }
}
]
}| Field | Description |
|---|---|
id, label, pluginName | The provider and the plugin that has its catalog. The built-in providers come from dash-core-providers. |
description, keyPlaceholder, keyConsoleUrl, docsUrl | Display text and links from the catalog, or null |
keys[].name | The key name |
keys[].kind | api_key for a pasted key, oauth for a subscription sign-in |
keys[].addedAt | ISO 8601 time when the HQ saved the key, or null if the HQ does not know |
keys[].account | The account of a sign-in (email and ChatGPT plan), or null |
signIn | kind (code or device_code) and the button label, or null if the provider has no sign-in |
Keys
curl -i -X PUT "$MOBILE_URL/mobile/v1/providers/anthropic/keys/default" \
-H "Authorization: Bearer $MOBILE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value":"sk-ant-..."}'| Status | Code | When |
|---|---|---|
400 | validation_failed | A key name that is not 1 to 64 letters, numbers, or hyphens |
400 | validation_failed | A value that is blank, more than 8,192 characters, or that contains whitespace |
400 | validation_failed | A body that is not { "value": string }, or a Content-Length of more than 16 KiB |
404 | not_found | An unknown provider (Provider not found), or a DELETE of a key that does not exist (Key not found) |
The HQ trims the value before it checks it. If you replace an OAuth sign-in with a pasted key, the key becomes a plain API key. After each write, the HQ refreshes the model list and the speech status on the next request.
Subscription sign-in
If the signIn field of a provider is not null, the provider also supports a subscription
sign-in. The HQ runs the full sign-in. It keeps the PKCE verifier, the state, and the tokens. A
sign-in object never contains a token.
{
"attemptId": "b2d4e6f8-1a3c-4e5f-9b7d-2c4e6a8b0d12",
"providerId": "openai",
"keyName": "chatgpt",
"kind": "device_code",
"status": "pending",
"expiresAt": "2026-09-24T12:15:00.000Z",
"authorizeUrl": null,
"verificationUrl": "https://auth.openai.com/codex/device",
"userCode": "ABCD-1234",
"error": null
}kind | Provider | How to finish |
|---|---|---|
code | Claude Pro or Max (Sign in with Claude), OpenRouter (Sign in with OpenRouter) | Open authorizeUrl and sign in. Post the code that the provider then shows to .../code. The HQ accepts code#state, a redirect URL, or the bare code. |
device_code | ChatGPT (Sign in with ChatGPT) | Show userCode and verificationUrl. The user enters the code at that URL on a device. Read the sign-in with GET until its status is not pending. |
- A code from a different sign-in, or a code that the provider rejects, gets
400validation_failed. A rejected code also makes the sign-in fail, because each code works one time only. - A failed sign-in has the reason in
error. - The HQ keeps sign-ins in memory. A
codesign-in expires after 10 minutes. Adevice_codesign-in expires at the time that the provider sets, or after 15 minutes. - After a sign-in ends, you can read it for 10 more minutes. After that, it is
404. - A new sign-in for the same key cancels a pending one.
- If the provider has no sign-in, the sign-in routes return
404not_found.
If a Claude or ChatGPT sign-in is successful, the HQ stores the key with kind: "oauth" and
refreshes it when necessary. An OpenRouter sign-in gives a usual API key. The HQ stores it with
kind: "api_key", and the key does not expire.
Speech
These routes transcribe audio and make speech through the speech provider of the HQ. Before you call
them, look for the speech-v1 capability in
GET /health. If the HQ has no speech service, it does not add the /speech
routes, and they return 404.
The speech routes use the MobileApiError envelope. Provider failures keep their own codes:
| Code | Status | Retryable | Meaning |
|---|---|---|---|
invalid | 400 | No | The provider rejected the input |
unauthorized | 401 | No | The provider rejected the speech credential of the HQ. This is not a bearer failure. |
too_large | 413 | No | Decoded audio of more than 8 MiB, or a request body that is too large |
too_long | 413 | No | Text of more than 4,000 characters |
configuration_unavailable | 500 | No | The HQ cannot safely read or preserve its speech settings |
provider | 502 | No | The provider refused or failed |
network | 502 | Yes | The call to the provider failed |
unavailable | 503 | Yes | No provider is configured for this kind. To fix it, add a credential in Desktop. |
For GET /speech/config and PATCH /speech/config, this configuration error identifies a problem with speech.json.
Repair the file or its permissions before you retry. See Configuration recovery.
Each speech route with a body checks Content-Length before it reads the body. If the length is
more than the limit, the route returns 413 too_large (request body too large). The limit is
12 MB for POST /speech/transcriptions. It is 64 KiB for PATCH /speech/config and
POST /speech/speech.
GET /speech/config
Returns the speech configuration of the HQ and the status of each provider.
{
"config": {
"stt": {
"provider": "openrouter",
"model": "openai/whisper-large-v3",
"language": "en"
},
"tts": {
"provider": "openrouter",
"model": "minimax/speech-2.8-turbo",
"voice": "English_expressive_narrator",
"speed": 1
},
"realtime": {
"provider": null
}
},
"providers": [
{
"id": "openrouter",
"capabilities": {
"transcription": true,
"speech": true,
"realtime": false
},
"available": true
},
{
"id": "realtime",
"capabilities": {
"transcription": false,
"speech": false,
"realtime": true
},
"available": false,
"reason": "no_provider_offers_realtime"
}
]
}| Field | Description |
|---|---|
config.stt | Transcription: provider, model, and optional language. If the provider finds the language itself, language is absent. |
config.tts | Speech: provider, model, voice, and optional speed (0.25 to 4) |
config.realtime.provider | Realtime provider, or null for none |
providers[] | One entry for each provider that the HQ can make, and a realtime entry. reason (no_credential or no_provider_offers_realtime) is present only when available is false. |
Errors: 401, 500 configuration_unavailable.
PATCH /speech/config
Merges a part of the configuration and returns the result in the same shape as
GET /speech/config. The merge is shallow for each section. A key that you do not send keeps its
current value.
{
"stt": {
"model": "openai/whisper-large-v3",
"language": "en"
},
"tts": {
"voice": "nova",
"speed": 1.25
},
"realtime": {
"provider": null
}
}sttacceptsprovider,model, andlanguage. To go back to the language detection of the provider, setlanguagetonull:{ "stt": { "language": null } }.ttsacceptsprovider,model,voice, andspeed.- If you send
realtime, it must containprovider, which can benull. Arealtimeobject withoutprovideris a400. - An unknown key at any level is a
400. The HQ does not ignore it.
Errors: 400, 401, 413, 500 configuration_unavailable.
GET /speech/models
Lists the models of the configured provider for one kind of speech. The HQ keeps the results in a cache for one hour for each provider.
| Query parameter | Type | Description |
|---|---|---|
kind | string | Necessary. transcription or speech. Other values get 400. |
{
"models": [
{
"id": "openai/whisper-large-v3",
"name": "Whisper Large v3",
"kind": "transcription"
},
{
"id": "minimax/speech-2.8-turbo",
"name": "MiniMax: Speech 2.8 Turbo",
"kind": "speech",
"voices": ["English_expressive_narrator", "English_radiant_girl"]
}
]
}voices is present only on speech models that have named voices.
Errors: 400, 401, 502, 503.
POST /speech/transcriptions
Transcribes one recorded clip. Send the audio as base64 in JSON, not as multipart.
{
"audio": "UklGRiwAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQgAAAAAAAAAAAAAAA==",
"format": "wav",
"language": "en"
}| Field | Type | Description |
|---|---|---|
audio | string | Necessary. Standard base64: only A-Za-z0-9+/=, no line breaks, and a length that is a multiple of 4. The decoded audio must be 1 byte to 8 MiB. |
format | string | Necessary. wav, m4a, mp3, flac, ogg, webm, or aac. |
language | string | Optional language hint for the provider |
The provider also limits the clip to 60 seconds.
{
"text": "Ship the speech routes.",
"durationSeconds": 2.5
}durationSeconds is present only when the provider reports it.
Errors: 400, 401, 413, 502, 503.
POST /speech/speech
Makes speech from text. This is the only Mobile API route with a success body that is not JSON.
{ "text": "Ship the speech routes." }| Field | Type | Description |
|---|---|---|
text | string | Necessary. 1 to 4,000 characters. Longer text gets 413 too_long. |
Send Accept: audio/mpeg, audio/wav. The response is one of these:
Content-Type: audio/mpeg. The HQ streams the audio as the provider makes it.Content-Type: audio/wav, sent as one body, when the configured model makes only raw PCM.
If an error occurs before the first audio chunk, the HQ returns a JSON MobileApiError. Be ready
to decode JSON on this route too.
Errors: 400, 401, 413, 502, 503.
Long-horizon work
These routes serve squad messages between agents, goals, routines, wake-ups, the daily limit of
unattended work, and the Needs you list. They exist only while /health lists
long-horizon-v1. Without it, each route returns 404 not_found, and a client hides these
surfaces. With it, a 404 means that the agent, goal, routine, or thread does not exist.
The same routes are on the loopback API with the management bearer. All of them use the
error envelope. Each schema is closed: the HQ adds a new response key only
with a new capability. Treat an unknown value of pausedReason, endedReason, verifiedBy, or
waitingReason like a missing key.
GET /agents/:id/autonomy
Returns the daily limit and today's unattended usage of an agent. Unattended work is work that the user did not start: goal iterations, routine runs, wake-ups, and squad messages. A turn that the user starts is never blocked.
{
"dailyLimitUsd": 5,
"dailyLimitTokens": 3000000,
"day": "2026-11-01",
"usage": { "usdEstimate": 1.25, "tokens": 420000, "noCostTokens": 0, "turns": 6 },
"limitReached": false,
"resetsAt": "2026-11-01T16:00:00.000Z"
}| Field | Description |
|---|---|
dailyLimitUsd, dailyLimitTokens | The limits. Only tokens of models with no price (usage.noCostTokens) count against dailyLimitTokens. |
day | The local day of the HQ, YYYY-MM-DD |
usage | usdEstimate, tokens, noCostTokens, and turns for today |
limitReached | true when the HQ starts no new unattended turn for this agent today |
resetsAt | The next local midnight |
Errors: 401, 404.
PATCH /agents/:id/autonomy
Changes the limits. Send dailyLimitUsd (a number, 0 or more), dailyLimitTokens (an integer, 0
or more), or both. Returns the same body as GET.
Errors: 400 (invalid JSON, an empty object, an unknown field, or a value that is not valid),
401, 404.
Goals
A goal makes the agent continue to work in one conversation until the goal is complete, or until it stops at a limit. Each conversation has at most one goal that is not finished.
| Route | Returns |
|---|---|
GET /conversations/:id/goal | A goal snapshot: agentId, conversationId, goal (the latest goal, or null when the conversation never had one), cursor (increases on each goal change), serverTime |
POST /conversations/:id/goal/commands | { requestId, outcome, snapshot }. 201 for a new goal, else 200. |
GET /agents/:id/goals | { items }: each goal of the agent that is not finished, then its 5 latest finished goals. An unknown agent gets an empty list. |
The goal record has these main fields:
| Field | Description |
|---|---|
id, agentId, conversationId | IDs |
createdBy | user or agent |
objective | The text of the goal |
status | active, waiting, paused, blocked, limit_reached, completed, or cancelled. Show plain "Goal" for a value that you do not know. |
activity | queued, working, verifying, stopping, or null |
reason | null, or why the goal waits, is blocked, or stopped: code, message (one sentence to show), wakeAt, repairAction |
pausedBy | user, agent, or null. The agent can resume only a goal that it paused. |
controlRevision | Increases on each control. Send it as expectedControlRevision. |
criteria | The checks. Each has id, description, provenance (user, agent, or derived), status (pending or verified), and optional verifiedBy. verifiedBy: "reply" means that only the reply text of the agent proves the check, not a recorded action. |
limits | maxIterations, maxDurationMs, maxProviderRequests, maxTokens (null is no token limit) |
usage | Provider requests, tokens, and usdEstimate (null when the model has no price) |
iteration, nextAction, latestCheckpoint | Progress |
A user goal has these default limits: 20 iterations, 2 hours, and 400 provider requests. The record
also has evidence, lastEvaluation, scopeRevision, currentTurnId, pursuingDurationMs, and
times. See GoalRecord in the contract for these fields.
A goal command has this body:
{
"agentId": "agent-01",
"requestId": "goal-command-01",
"goalId": null,
"expectedControlRevision": null,
"operation": { "type": "create", "objective": "Ship the release notes" }
}operation.type | Other fields |
|---|---|
create | objective, optional limits, optional images. Send goalId and expectedControlRevision as null. |
pause, resume, clear | None |
edit | objective |
limits | limits: maxIterations, maxDurationMs, and maxTokens (null removes it). Pause the goal first. |
For each operation except create, send the goalId and the controlRevision that you saw.
requestId is unique in the conversation. If you send the same requestId and operation again,
the HQ returns the first result with outcome: "replayed". outcome is applied, unchanged, or
replayed.
A refused command has code: "validation_failed" and details.reason. details.snapshot can hold
the current goal, so you can refresh without another read.
| Status | details.reason |
|---|---|
400 | validation_failed |
403 | forbidden. For example, only the user can clear or edit a goal. |
404 | goal_not_found (code not_found). A 404 with no details means that the conversation does not exist. |
409 | goal_exists, goal_ended, command_id_conflict, revision_conflict, goal_busy, budget_reached, or chat_archived (restore the chat first). Only goal_busy is retryable. |
A goal change also sends conversation:changed on /events.
Routines and wake-ups
A routine is a scheduled task of an agent. A wake-up (purpose: "wake") is a one-time run that an
agent sets for itself, in its own chat. Only agents create wake-ups.
| Route | Result |
|---|---|
GET /agents/:id/routines | { items }: routines and wake-ups, newest first |
POST /agents/:id/routines | 201 with the new routine (createdBy: "user") |
GET /routines/:routineId | One routine |
PATCH /routines/:routineId | 200 with the changed routine |
DELETE /routines/:routineId | 204, no body. Deletes the routine or cancels the wake-up. Its run history stays. |
POST /routines/:routineId/run | 202 with the run. The daily limit applies: a refused run is skipped with skipReason: "budget". |
GET /routines/:routineId/runs | { items, nextCursor }, newest first. Query limit (1 to 100, default 20) and cursor. |
The create body has title (1 to 80 characters), instructions (1 to 8192 characters), and
schedule. Optional fields are destination (main_chat or new_chat_each_run) and notify
(always or with_updates). PATCH accepts the same fields, all optional, and state (active or
paused).
schedule.kind | Other fields |
|---|---|
daily, weekdays | at (local HH:MM), timezone (IANA name) |
weekly | at, days (ISO weekdays, 1 is Monday), timezone |
interval | everyMinutes, from 15 to 10080 |
once | at, an RFC 3339 time |
cron | cron, timezone |
The routine object has id, agentId, purpose, title, instructions, schedule,
destination, notify, and createdBy. It also has state, expiresAt, nextRunAt, lastRun,
runCount, missedCount, createdAt, and updatedAt. A wake-up has
destination: "conversation" and a conversationId.
| Field | Values |
|---|---|
state | active, paused, expired, or ended |
pausedReason | Only while state is paused: failures (three failed runs in a row), user, or agent |
endedReason | Only when the routine ended in an unusual way: agent_deleted |
A run has id, routineId, scheduledFor, startedAt, finishedAt, status, optional
conversationId, summary (the first 280 characters of the reply), tokens, and usdEstimate.
| Field | Values |
|---|---|
status | running, ran, quiet, skipped, or failed |
startedAt | When the turn of the run started. null while the run waits to start, and on a skip. |
waitingReason | Only while status is running and startedAt is null: daily_limit or chat_busy. Show the run as a run that waits, not as a run in progress. |
skipReason | budget, hq_off, agent_disabled, or chat_busy_timeout. A skipped run with no skipReason was skipped because its routine was paused or ended. |
Refusals have code: "validation_failed" and details.reason:
| Status | details.reason | When |
|---|---|---|
400 | validation_failed | A body or query that is not valid, or a schedule under 15 minutes |
404 | routine_not_found (code not_found) | No such routine. A 404 with no details means that the agent does not exist. |
409 | limit_reached | More than 20 active routines for the agent, or more than 100 in the HQ. A resume counts as a create. |
409 | routine_ended | The routine has ended or expired |
Squad message threads
Agents send messages to each other in threads.
| Route | Result |
|---|---|
GET /agents/:id/threads | { items, nextCursor }: the threads of the agent, latest message first. Query cursor. |
GET /threads/:threadId | { thread, messages }: the thread and each message in order |
POST /threads/:threadId/pause | The thread, with state: "paused" and pausedReason: "user" |
POST /threads/:threadId/resume | The open thread. It lets the agents send 12 more messages. |
POST /threads/:threadId/close | The closed thread |
A thread has id, startedByAgentId, participants, messageCount, maxHop, state,
pausedReason, createdAt, and lastMessageAt. Each participant has agentId, agentName, and
an optional conversationId. state is open, paused, or closed. pausedReason is
message_limit, hop_limit, user, or null.
A participant keeps its agentName after the agent is deleted.
A message has id, threadId, fromAgentId, fromAgentName, toAgentId, toAgentName,
body, hop, status (delivered, queued, or refused), reason (the refusal code, else
null), createdAt, and deliveredAt. fromConversationId is absent when the message came from a
messaging-app chat. toConversationId is absent until the message has a chat on the receiver side.
Errors: 401, 404. A pause, resume, or close of a closed thread returns 400
validation_failed, The thread is closed.
GET /needs-you
Returns each item that waits for the user, for all agents, oldest first. The HQ makes the list. Do not build it from other reads.
{
"items": [
{
"id": "limit:agent-01:2026-11-01",
"kind": "limit",
"agentId": "agent-01",
"agentName": "Writer",
"title": "Writer reached today's limit of USD 5.",
"createdAt": "2026-11-01T09:30:00.000Z",
"ref": {}
}
]
}kind | The item |
|---|---|
question | A question card waits for an answer. ref.toolCallId |
goal | A goal waits for input or is blocked. ref.goalId |
limit | The agent reached its daily limit |
thread | A squad thread is paused. ref.threadId |
routine | A routine paused after failures. ref.routineId |
title is one sentence that the HQ writes. conversationId is absent when the item has no chat.
For a kind that you do not know, show the agent, title, and an action to open the chat when
conversationId is set. ref is open: ignore keys that you do not know.
Errors: 401, 404.
Chat WebSocket
Live turns, Steer, Follow Up, and voice use the chat WebSocket at /ws/chat, on the relay
hostname. HQ Chat WebSocket describes the frame protocol. This section
covers only the connection.
The socket uses the same mobile bearer. The HQ checks these items in this order:
- If the upgrade has an
Authorizationheader that is not empty, the header must beBearer <mobile-token>. The HQ looks at nothing else. - If not, the HQ accepts a
?token=<mobile-token>query parameter or a?ticket=<ticket>fromPOST /ws-ticket.
If the check fails, the upgrade completes, and then the HQ closes the socket with code 4001
Unauthorized.
Native clients must send the Authorization header. Browsers cannot set headers on a WebSocket
upgrade. A browser gets a ticket over HTTP and puts it in the URL, so the long-lived bearer stays
out of URLs and logs. Through the relay, a browser sends the relay credential as a WebSocket
subprotocol. See Relay API.
POST /ws-ticket
Makes a single-use ticket for one WebSocket upgrade. Takes no body.
{ "ticket": "0123456789abcdef0123456789abcdef", "expiresAt": "2026-08-29T12:00:30Z" }| Field | Type | Description |
|---|---|---|
ticket | string | Random ticket. The HQ makes 32 random bytes as 64 lowercase hex characters. |
expiresAt | string | ISO 8601 expiry time, 30 seconds after the HQ makes the ticket |
Use it immediately:
wss://<gatewayId>.<zone>/ws/chat?ticket=<ticket>- A ticket works one time. When the HQ redeems it, the HQ removes it, also if it expired.
- The HQ uses the ticket only when the upgrade has no
Authorizationheader. If a header is present, only the header counts, and the HQ does not use the ticket. - One ticket store serves the full HQ, so a ticket from one namespace works with each socket.
Errors: 401.
Steer and Follow Up
Before a client opens a conversation, it does these steps:
- Call
GET /mobile/v1/health. Make sure that it hascontractVersion: 1. - Call
GET /mobile/v1/identitywith the mobile bearer. Make sure that the HQ is the one you paired with.
You can always send behavior: "auto". See Commands and durable acknowledgements.
Through the relay, /mobile/v1 also needs the per-device relay credential. The HQ does not accept
the management bearer.
If GET /mobile/v1/health is not available or does not have contractVersion: 1, the HQ is older
than the client. Tell the user to update the HQ. The client has no older protocol to use.
Snapshot and replay
Paths in this table are relative to /mobile/v1. All of them need the mobile bearer.
| Route | Result |
|---|---|
GET /conversations/:id/bootstrap | The conversation, messages, pending inputs, queue state, and throughSeq |
GET /conversations/:id/messages | Messages with their run, segment, and delivery IDs. Accepts limit and before |
GET /agents/:agentId/conversations/:conversationId/events?sinceSeq=N | { frames, throughSeq } after the cursor |
GET /events | Conversation cache invalidations over SSE |
The loopback management server has the same reads with the management bearer:
/conversations/:id/bootstrap, /conversations/:id/messages, and
/agents/:agentId/conversations/:conversationId/events?sinceSeq=N.
GET /agents/:agentId/conversations/:conversationId/events needs sinceSeq. If it is
absent or not a non-negative integer, the route returns 400 validation_failed. The route
accepts no other query parameter.
{
"frames": [
{
"type": "accepted",
"id": "turn-01",
"conversationId": "00000000-0000-4000-8000-000000000101",
"runId": "turn-01",
"segmentTurnId": "turn-01",
"seq": 8,
"userMessageId": "00000000-0000-4000-8000-000000000111",
"assistantMessageId": "00000000-0000-4000-8000-000000000114",
"revision": 3
},
{
"type": "event",
"id": "turn-01",
"conversationId": "00000000-0000-4000-8000-000000000101",
"runId": "turn-01",
"segmentTurnId": "turn-01",
"seq": 10,
"event": { "type": "text_delta", "text": "Working." }
}
],
"throughSeq": 10
}frames contains the sequenced frames: accepted, event, done, error, input_accepted,
input_updated, input_removed, input_failed, input_delivered, queue_paused, and
queue_resumed. HQ Chat WebSocket describes each frame.
seq is a dense sequence for each conversation. Store a cursor only after you apply its frame. After a compaction, read a new bootstrap, and keep the history that you already have by
message ID.
Subscribe before you send
Connect to /ws/chat with the native Authorization header or a single-use ticket. The first
frame must be hello. It names the contract version and nothing else:
{"type":"hello","contractVersion":1}hello_ack has the same two fields:
{"type":"hello_ack","contractVersion":1}Wait for hello_ack. Then subscribe from the watermark of the bootstrap:
{"type":"subscribe_conversation","id":"00000000-0000-4000-8000-000000000002","agentId":"agent-01","conversationId":"00000000-0000-4000-8000-000000000001","sinceSeq":0}conversation_subscribedcontains the replay watermark (throughSeq).- The subscription stays open across runs. A terminal frame does not close it.
- To connect again, send the last
seqthat you applied. - A first frame other than
hellocloses the socket with1002hello_required. - The HQ closes the socket with
1002after a bad negotiation frame or protocol frame. - If the HQ rejects a command, it sends
command_rejectedand keeps the subscription open.
Commands and durable acknowledgements
Each command has an id that the client makes. An enqueued input also has a different inputId.
If you are not sure that a command arrived, send it again with the same two IDs and exactly the
same payload. The HQ rejects an ID that you use again for a different payload. A normal
message.id stays the outer run ID.
| Command | Necessary fields | Behavior |
|---|---|---|
message | Agent, channel, conversation, text, resumable: true | Start a run when the conversation is free |
enqueue_input | Agent, channel, conversation, inputId, text, behavior | steer guides the active run. followUp queues a later run. auto lets the HQ choose. |
edit_follow_up | Conversation, input, expectedRevision, text | Change an item that did not start. New images replace all of its images. |
remove_follow_up | Conversation, input, expectedRevision | Remove a queued item |
resume_follow_ups | Conversation, expectedQueueRevision | Resume a paused queue |
cancel | id of the active run | Stop that run. If Follow Ups wait, the HQ pauses the queue. |
answer | Run ID, questionId, answer | Answer a question from the agent |
A Steer also needs expectedActiveTurnId, so it cannot go to a different run by accident. An
auto enqueue also needs expectedActiveTurnId, because the HQ can make it a Steer.
The HQ answers with these frames:
| Frame | Meaning |
|---|---|
input_accepted | The HQ accepted an enqueued input |
input_updated, input_removed | An edit or a removal is complete |
input_delivered | Links the input to its run, segment, and message IDs |
input_failed | The input failed |
queue_paused, queue_resumed | The queue state changed |
command_rejected | The HQ rejected the command. The client keeps the draft. |
If an item revision is old, read the item again before the next edit or removal.
If the HQ cannot choose a route for an auto enqueue, it sends command_rejected with
code: "delivery_choice_required", retryable: false, and
details: { suggested?: "steer" | "followUp", confidence?: number }. The HQ sends this code only
for an auto command. Do these steps:
- Show the Steer and Follow Up choice, with
details.suggestedselected. - Send the choice of the user as a new
enqueue_inputwith an explicitbehavior. - Use a new
idand a newinputId. The rejected command made no pending row. - Keep the text and images in the composer until the HQ accepts a command.
Pending inputs and stored messages have routedBy: "auto" when the HQ chose the delivery. Show
these entries as automatic, for example Auto-steered and Auto-queued. Use the same Steer
and Follow Up display for all other parts.
Delivery rules
- The HQ delivers a Steer only at a safe agent boundary.
- The user row of a Steer records
pending,delivered, ornot_delivered. Show delivered rows as Steered. - Each delivered Steer starts a new assistant segment in the same outer run.
- A late Steer never becomes a Follow Up.
- Follow Ups run in order, one at a time. A failure pauses the queue until the user clicks Resume Follow Ups.
cancelstops the active run. If Follow Ups wait, the HQ also pauses the queue and sendsqueue_paused. No Follow Up starts until the client sendsresume_follow_ups.
Limits
| Limit | Value |
|---|---|
| Pending Follow Ups for each conversation | 20 |
| Pending Steers for each conversation | 20 |
| Total pending payload for each conversation | 100 MiB |
| Images for each input | 4 (JPEG, PNG, GIF, or WebP) |
| Size of one image | 5 MiB |
| Total decoded image data for each input | 12 MiB |
The HQ rejects a send or an edit that goes over these limits.