dashsquad

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 HQBase URLTransportNotes
Relayhttps://<gatewayId>.<zone>/mobile/v1HTTPS on 443, terminated at the relayThe normal path for phones and the web app. See Relay API.
Loopback management serverhttp://127.0.0.1:9300/mobile/v1Plain HTTP on loopbackThe 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"
}
FieldRule
vMust be 2. Clients reject the old plaintext version 1 payload and the removed version 3 (LAN) payload.
hostThe relay hostname of the HQ. No scheme, path, port separator, or whitespace.
secureMust be true
mgmtToken, chatTokenBoth carry the mobile bearer. Clients trim both, require them to be equal, and store one value.
relayCredentialThe 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_TOKEN environment 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 Bearer and 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 chatToken from the control plane. See Relay Control Plane API.
  • If the HQ has a management token but no mobile token, all mobile routes except /health return 401. 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:

ConfigurationAllowed origins
DASH_WEB_ORIGINS not set, HQ not enrolled on a relayNone. CORS is off.
DASH_WEB_ORIGINS not set, HQ enrolled on a relayhttps://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 listExactly 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 emptyNone. Use this to turn off browser access.

If the allowlist is not empty, the HQ applies these CORS rules to /mobile/v1 only:

RuleValue
Origin matchExact string match. No wildcards and no subdomain match.
Access-Control-Allow-MethodsGET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-HeadersAuthorization, Content-Type, If-Match, x-dash-relay-credential
Access-Control-Expose-HeadersETag, so that a script can read the conversation revision
CredentialsNever. 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
}
FieldTypeDescription
codestringOne of the codes below
errorstringA message for people. Do not parse it.
retryablebooleantrue if the same request can succeed later
detailsobjectOptional. More data for some codes, for example current on revision_conflict
CodeUsual statusMeaning
unauthorized401Missing or wrong mobile bearer. A speech provider credential failure also uses this code.
not_found404, or 410 for a deleted conversationThe agent, conversation, sub-agent, definition, provider, or key does not exist
validation_failed400, 403, 409, 422The request is not correct, or the resource is in a state that blocks it. Some routes add details.reason.
revision_conflict409If-Match names an old revision
conversation_busy409The conversation has an active turn
gateway_offline500, 503500: internal HQ error. 503: the HQ is about to stop. Both are retryable.
delivery_choice_requiredOnly on the chat WebSocket. See Commands and durable acknowledgements.
rate_limited, capability_requiredReserved by the contract. No mobile route returns them now.
too_large, too_long, invalid, provider, network, unavailable413, 400, 502, 503Speech errors from the speech provider. See Speech.

Two rules apply to each request that changes data (each method except GET, HEAD, and OPTIONS):

StatusBodyWhen
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 a null field do not have the same meaning. The HQ can omit an optional field, for example deletedAt.
  • 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/v1 and to the loopback API): the header X-Dashsquad-Capabilities, a comma-separated list. The HQ ignores values that it does not know.
  • On the chat WebSocket: capabilities in the hello frame. See HQ Chat WebSocket.
X-Dashsquad-Capabilities: long-horizon-v1, provider-recovery-v1, chat-archive-v1, chat-tools-v1

Send 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.

CapabilityWithout it, the HQ
long-horizon-v1Sends 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-v1Removes providerFailure from error frames and stored error events, and retryOf from accepted frames
chat-archive-v1Removes archivedAt from conversations
chat-tools-v1Sends 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.

MethodPathAuthPurpose
GET/healthNoneHealth, API version, and capabilities
GET/identityBearerThe HQ's stable relay identity
GET/agentsBearerList agents
POST/agentsBearerCreate an agent
GET/agents/:idBearerRead one agent
PUT/agents/:idBearerRename an agent, or change its model or system prompt
DELETE/agents/:idBearerDelete an agent
POST/agents/:id/enableBearerEnable an agent
POST/agents/:id/disableBearerDisable an agent
GET/agents/:id/skillsBearerList an agent's skills (read-only)
GET/agents/:id/memoryBearerList an agent's memories
GET/agents/:id/memory/:nameBearerRead one memory
DELETE/agents/:id/memory/:nameBearerDelete one memory
GET/agents/:id/subagent-typesBearerThe sub-agent roster an agent can spawn
GET/agents/:id/subagent-definitionsBearerList the agent's own definition files
GET/agents/:id/subagent-definitions/:nameBearerRead one definition
PUT/agents/:id/subagent-definitions/:nameBearerCreate or replace a definition
DELETE/agents/:id/subagent-definitions/:nameBearerDelete a definition
GET/modelsBearerModels available for agent configuration
GET/conversationsBearerList conversations, newest first
POST/conversationsBearerCreate a conversation (idempotent)
GET/conversations/:idBearerRead a conversation. Also returns tombstones
PATCH/conversations/:idBearer + If-MatchUpdate the title or effort
DELETE/conversations/:idBearer + If-MatchDelete a conversation's content
POST/conversations/:id/archiveBearerArchive a conversation
POST/conversations/:id/restoreBearerRestore an archived conversation
GET/conversations/:id/messagesBearerPage through persisted messages
GET/conversations/:id/bootstrapBearerConversation, messages, and queue in one read
GET/conversations/:id/subagentsBearerA conversation's sub-agents
POST/subagents/:id/stopBearerCancel a sub-agent and its descendants
POST/subagents/:id/resumeBearerSend a message to a sub-agent
GET/agents/:agentId/conversations/:conversationId/eventsBearerReplay a conversation's event log
GET/eventsBearerServer-Sent Events stream of changes
GET/speech/configBearerRead speech configuration
PATCH/speech/configBearerUpdate speech configuration
GET/speech/modelsBearerList speech models
POST/speech/transcriptionsBearerTranscribe a clip
POST/speech/speechBearerSynthesize speech
GET/providersBearerList AI providers and their keys (name, kind, when added, signed-in account)
PUT/providers/:id/keys/:nameBearerAdd or replace a provider key
DELETE/providers/:id/keys/:nameBearerRemove a provider key
POST/providers/:id/sign-inBearerStart a Claude or ChatGPT sign-in
GET/providers/:id/sign-in/:attemptIdBearerRead the status of a sign-in
POST/providers/:id/sign-in/:attemptId/codeBearerFinish a Claude sign-in with its code
DELETE/providers/:id/sign-in/:attemptIdBearerCancel a sign-in
POST/ws-ticketBearerMint a single-use WebSocket ticket
GET, PATCH/agents/:id/autonomyBearerDaily limit and unattended usage. See Long-horizon work.
GET/conversations/:id/goalBearerThe latest goal of a conversation
POST/conversations/:id/goal/commandsBearerOne goal command
GET/agents/:id/goalsBearerThe goals of an agent
GET, POST/agents/:id/routinesBearerList routines and wake-ups, or create a routine
GET, PATCH, DELETE/routines/:routineIdBearerRead, change, or delete one routine
POST/routines/:routineId/runBearerStart one run now
GET/routines/:routineId/runsBearerRun history
GET/agents/:id/threadsBearerSquad message threads of an agent
GET/threads/:threadIdBearerOne thread and its messages
POST/threads/:threadId/pause, /resume, /closeBearerPause, resume, or close a thread
GET/needs-youBearerItems 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"
  ]
}
FieldTypeDescription
status"healthy"Always healthy when the HQ answers
startedAtstringISO 8601 time the HQ process started
pidintegerThe HQ's process ID
agentsintegerNumber of registered agents
channelsintegerNumber of registered channels
apiVersion1Mobile API version
contractVersion1The 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.
capabilitiesstring[]Features that depend on the HQ's runtime state. See below.
CapabilityWhen present
providers-v1When the HQ mounts the AI providers routes
speech-v1Only 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-v1While 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-v1Always on a current HQ. The HQ attributes provider failures and accepts retryOf. See HQ Chat WebSocket.
chat-archive-v1Always on a current HQ. Gate archive and restore on it.
cancel-step-v1Always on a current HQ. The socket accepts cancel_step.
agent-rename-v1Always on a current HQ. PUT /agents/:id accepts name.
chat-tools-v1Always 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"
}
FieldTypeDescription
gatewayIdstringThe HQ's gateway ID, the first DNS label of its relay hostname
publicKeystringThe 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:

FieldTypeDescription
idstringOpaque agent ID
namestringDisplay name
configobjectThe configuration of the agent. See below.
statusstringregistered, active, or disabled
registeredAtstringISO 8601 registration time
effortLevelsstring[]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:

KeyContents
fallbackModels, tools, mcpServers, plugins, providersString arrays
skillspaths, urls, learning (auto, on, or off), and minToolCalls
workspace, maxTokensWorkspace path and output token limit
swarmThe older sub-agent limits: enabled, maxConcurrentWorkers, maxWorkersPerRun, maxSteersPerWorker, maxRunSeconds, allowedModels
subagentsenabled, delegation (auto or explicit), allowedTypes, allowedModels, modelAliases, maxConcurrent, maxPerTurn, maxRunSeconds, maxDepth
memoryenabled and sweep (auto, on, or off). If the block is absent, memory is on with sweep auto.
locationenabled. If the block is absent, location is on.
messagingsend 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.
autonomydailyLimitUsd 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.

StatusCodeWhen
400validation_failedThe 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)
401unauthorized
409validation_failedAnother agent has this name. details is { "field": "name", "reason": "taken" }. The HQ compares names without case.
500gateway_offlineUnexpected 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.

StatusCodeWhen
400validation_failedNot valid JSON, an empty object, an unknown field, or a value that is not valid
400validation_failedA name that is not valid. details.field is name, and details.reason is empty, too_long (with details.maxLength: 64), or invalid
401unauthorized
404not_foundNo such agent
409validation_failedThe HQ is about to delete the agent
409validation_failedAnother agent has this name. details is { "field": "name", "reason": "taken" }
500gateway_offlineUnexpected 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"
  }
]
FieldTypeDescription
namestringSkill name
descriptionstringWhen to use the skill
triggerstringOptional trigger keyword
sourcestringmanaged, agent, remote, or plugin
contentstringOptional. 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
  }
]
FieldTypeDescription
namestringMemory name
descriptionstringOne-line summary
typestringuser, feedback, project, or reference
sourcestringagent, sweep, user, or import
createdAt, updatedAtstringCalendar dates (YYYY-MM-DD), not timestamps
sizeintegerContent 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" }
StatusWhen
401Missing or wrong bearer (envelope body)
404Unknown agent or memory
400The memory store rejected the name
503Memory is off or not configured for this agent
500Unexpected 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.

FieldTypeDescription
typesobject[]The types. See below.
unknownAllowedTypesstring[]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-]*$.

StatusCodeWhen
400validation_failed:name does not match the pattern
401unauthorized
404not_foundNo 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." }
FieldTypeDescription
rawstringNecessary. 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" }
StatusCodeWhen
400validation_failedBad :name, JSON that is not valid, a missing or blank raw, or a raw that is too large
401unauthorized
404not_foundNo such agent
422validation_failedraw 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"
}
FieldTypeDescription
modelsobject[]value (a provider/model string for the model of an agent), label, and provider
sourcestringlive if the list came from the providers, bootstrap if it came from the bundled catalogs
errorsobjectMap of provider to error message, for providers that did not return a list
fetchedAtstringISO 8601 time of the last fetch
supportedModelsReviewedAtstringThe 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
}
FieldTypeDescription
idstringUUID, or sub_<ulid> for a sub-agent conversation
agentIdstringThe agent that owns the conversation
agentNamestringThe agent's name when the conversation was created
titlestringTitle. New Conversation until one is set.
revisionintegerStarts at 1 and increases on every change. Used for ETag and If-Match.
statusstringidle, running, interrupted, archived, or deleted
activeTurnIdstring or nullThe active turn, or null
lastSeqintegerSequence number of the latest replayable chat frame. See Replay.
lastMessagePreviewstring or nullShort preview of the latest message
createdAt, updatedAtstringISO 8601 times
queuePausedbooleantrue while the queue of Follow Up messages is paused
queueRevisionintegerRevision of the queue. It increases on every queue change.
pendingFollowUpCountintegerFollow Up messages that wait to run
effortstring or nullThe 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.
deletedAtstringPresent only on a deleted conversation
archivedAtstring or nullOnly with chat-archive-v1. When the user archived the conversation, or null. An agent delete archives the conversation but does not set this field.
goalobject or nullOnly 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.
kindstringuser, or subagent for a sub-agent's own conversation
parentConversationId, parentTurnId, subagentPresent 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.

StatusCodeWhen
401unauthorized
404not_foundNo such conversation
409validation_failedThe HQ cannot archive the conversation now. details.reason tells why. See below.
details.reasonMeaning
runningA turn is active. details.activeTurnId names it. Stop the turn first.
goalThe conversation has a goal that is active or waiting. Pause or clear the goal first. A paused goal does not block the archive.
wake_upA wake-up of the agent waits to run in this conversation. Cancel the wake-up first.
agent_removedThe 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.

StatusCodeWhen
401unauthorized
404not_foundNo such conversation
409validation_faileddetails.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 limit that 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 parameterTypeDescription
agentIdstringOptional. Only this agent's conversations.
limitintegerOptional. 1 to 100, default 50.
cursorstringOptional. 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"
}
FieldTypeDescription
agentIdstringRequired. The agent that owns the conversation.
requestIdstringRequired. A client-generated key, unique per intended conversation.
titlestringOptional. 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.

StatusCodeWhen
400validation_failedInvalid JSON, not an object, an unknown field, or a blank or non-string value
401unauthorized
404not_foundNo 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"
}
FieldTypeDescription
titlestringOptional. Non-blank, trimmed.
effortstring or nullOptional. 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.

StatusCodeWhen
400validation_failedMissing or malformed If-Match, invalid JSON, an empty object, an unknown field, a blank title, or an unknown effort value
401unauthorized
404not_foundNo such conversation
409validation_failedThe conversation is archived
409revision_conflictIf-Match is stale. details.current holds the current conversation.
410not_foundThe 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:

StatusCodeWhen
400validation_failedMissing or malformed If-Match
404not_foundNo such conversation
410not_foundAlready deleted
409validation_failedThe conversation is archived
409conversation_busyA turn is active. details.activeTurnId names it. Stop the turn, then try again.
409revision_conflictIf-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 parameterTypeDescription
limitintegerOptional. 1 to 200, default 100.
beforestringOptional. 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
}
FieldTypeDescription
items[].rolestringuser or assistant
items[].statusstringaccepted, streaming, completed, cancelled, failed, or interrupted
items[].contentobjectOne of the three content shapes below
items[].runIdstringThe run that the message belongs to. A run can span several turns when queued messages are delivered into it.
items[].segmentIndexintegerPosition of the message's segment within its run, from 0
items[].deliveryKindstringnormal, steer (sent into an active turn), or follow_up (queued until the turn ended)
items[].deliveryStatusstringOptional. pending, delivered, or not_delivered, for a steer or Follow Up message
items[].originstringOptional. Who caused the turn. See the origin table below.
items[].originDetailobjectOptional. More data about the origin. kind is the same as origin. Absent for user, notification and parent.
nextCursorstring or nullCursor for older messages, or null when there are none
throughSeqintegerThe conversation's lastSeq at read time

Status rules for a client:

  • A user row shows the delivery of its prompt, not the result of the run. When the run ends, each delivered user row of the run changes from accepted to completed, also when the run fails or is cancelled.
  • The assistant row of the run shows the result of the run: completed, cancelled, failed, or interrupted.
  • If the HQ does not deliver a Steer message, its user row changes to failed, with deliveryStatus: "not_delivered".

Message origins:

originMeaningoriginDetail
userThe user sent the message
notificationThe HQ started the turn, for example when a background sub-agent sends its report
parentA turn in a sub-agent conversation that the parent agent started
agentA message from another squad member. Needs long-horizon-v1.fromAgentId, fromAgentName, threadId, messageId
routineA scheduled routine run. Needs long-horizon-v1.routineId, runId, title, scheduledFor
wakeA wake-up that the agent set for itself. Needs long-horizon-v1.routineId, note
goalThe next iteration of a conversation goal. Needs long-horizon-v1.goalId, iteration (from 1)
handoffThe 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.typeFieldsDescription
usertext, optional imagesWhat the user sent. Each image has mediaType (image/jpeg, image/png, image/gif, or image/webp) and base64 data.
assistanteventsThe 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.
noticekind, text, optional conversationIdA standalone notice, such as Learned: write-files. text is ready to show. Notices use the assistant role.

Notice kinds:

kindMeaning
skill_learned, memory_savedThe agent learned a skill or saved a memory after a turn
goal, routine, wakeA goal event, a routine run, or a wake-up note. Needs long-horizon-v1.
agent_renamedSomeone renamed an agent: the user, the agent itself, or another agent. The HQ sends this kind to every client.
chat_renamedAn agent renamed this chat. Needs chat-tools-v1.
chat_startedAn 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
}
FieldTypeDescription
conversationobjectThe conversation object
messages, nextCursorThe newest page of messages, the same as /messages returns
pendingInputsobject[]Queued Steer messages, and Follow Up messages that are queued or in delivery, in queue order
pendingInputs[].kindstringsteer or follow_up
pendingInputs[].statestringqueued, delivering, delivered, removed, or failed
pendingInputs[].revisionintegerIncreases on each edit. Chat commands that edit or remove an input must name it.
pendingInputs[].imagesobject[]Optional. Same shape as message images.
queuePaused, queueRevisionThe queue state at read time
throughSeqintegerThe 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
    }
  ]
}
FieldTypeDescription
idstringThe conversation ID of the child
namestringOptional display name
typestringThe sub-agent type of the child
descriptionstringShort task description
statusstringSee above
backgroundbooleantrue if the child runs in the background
depthintegerNesting depth. 1 for a child of a user conversation.
startedAt, endedAtstringISO 8601 times. endedAt is absent while the child is active.
usageobjectOptional. inputTokens and outputTokens.
toolCallCountintegerTool calls until now
reportstringThe scanned output of the child. Absent until the child gets to a terminal status.
oneShotbooleantrue 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.

StatusCodeWhen
401unauthorized
404not_foundNo such sub-agent. A user conversation ID also gets 404.
409validation_failedThe 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" }
FieldTypeDescription
messagestringNecessary. Not blank.
requestIdstringOptional. 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" }
FieldTypeDescription
okbooleantrue if the HQ delivered the message
statusstringThe status of the child: spawning, or a sub-agent status
modestringqueued 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.

StatusCodeWhen
400validation_failedJSON that is not valid, a blank message, or a requestId that is not valid
401unauthorized
404not_foundNo such sub-agent
409validation_failedA 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 parameterTypeDescription
sinceSeqintegerRequired. 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.

StatusCodeWhen
400validation_failedA missing sinceSeq, any other query parameter, a repeated parameter, or a value that is not a non-negative integer
401unauthorized
404not_foundNo 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-stream

The 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}
EventSent when
conversation:changedA conversation was created, updated, retitled automatically, archived, given a notice, or changed as a turn ran. revision is the new revision.
conversation:deletedA 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.

MethodPathPurpose
GET/providersAll providers that the HQ has a catalog for, with the name and kind of each key. Never a key value.
PUT/providers/:id/keys/:nameAdd or replace one key. Body: { "value": "<api key>" }. Returns { "ok": true }.
DELETE/providers/:id/keys/:nameRemove one key and the OAuth sign-in data that the HQ stored with it. Returns { "ok": true }.
POST/providers/:id/sign-inStart a subscription sign-in. Body: { "keyName": "<name>" }. Returns the sign-in.
GET/providers/:id/sign-in/:attemptIdRead a sign-in. Its status is pending, complete, failed, cancelled, or expired.
POST/providers/:id/sign-in/:attemptId/codeFinish a code sign-in. Body: { "code": "<code from the provider>" }. Returns the sign-in.
DELETE/providers/:id/sign-in/:attemptIdCancel 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" }
    }
  ]
}
FieldDescription
id, label, pluginNameThe provider and the plugin that has its catalog. The built-in providers come from dash-core-providers.
description, keyPlaceholder, keyConsoleUrl, docsUrlDisplay text and links from the catalog, or null
keys[].nameThe key name
keys[].kindapi_key for a pasted key, oauth for a subscription sign-in
keys[].addedAtISO 8601 time when the HQ saved the key, or null if the HQ does not know
keys[].accountThe account of a sign-in (email and ChatGPT plan), or null
signInkind (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-..."}'
StatusCodeWhen
400validation_failedA key name that is not 1 to 64 letters, numbers, or hyphens
400validation_failedA value that is blank, more than 8,192 characters, or that contains whitespace
400validation_failedA body that is not { "value": string }, or a Content-Length of more than 16 KiB
404not_foundAn 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
}
kindProviderHow to finish
codeClaude 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_codeChatGPT (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 400 validation_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 code sign-in expires after 10 minutes. A device_code sign-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 404 not_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:

CodeStatusRetryableMeaning
invalid400NoThe provider rejected the input
unauthorized401NoThe provider rejected the speech credential of the HQ. This is not a bearer failure.
too_large413NoDecoded audio of more than 8 MiB, or a request body that is too large
too_long413NoText of more than 4,000 characters
configuration_unavailable500NoThe HQ cannot safely read or preserve its speech settings
provider502NoThe provider refused or failed
network502YesThe call to the provider failed
unavailable503YesNo 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"
    }
  ]
}
FieldDescription
config.sttTranscription: provider, model, and optional language. If the provider finds the language itself, language is absent.
config.ttsSpeech: provider, model, voice, and optional speed (0.25 to 4)
config.realtime.providerRealtime 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
  }
}
  • stt accepts provider, model, and language. To go back to the language detection of the provider, set language to null: { "stt": { "language": null } }.
  • tts accepts provider, model, voice, and speed.
  • If you send realtime, it must contain provider, which can be null. A realtime object without provider is a 400.
  • 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 parameterTypeDescription
kindstringNecessary. 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"
}
FieldTypeDescription
audiostringNecessary. 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.
formatstringNecessary. wav, m4a, mp3, flac, ogg, webm, or aac.
languagestringOptional 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." }
FieldTypeDescription
textstringNecessary. 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"
}
FieldDescription
dailyLimitUsd, dailyLimitTokensThe limits. Only tokens of models with no price (usage.noCostTokens) count against dailyLimitTokens.
dayThe local day of the HQ, YYYY-MM-DD
usageusdEstimate, tokens, noCostTokens, and turns for today
limitReachedtrue when the HQ starts no new unattended turn for this agent today
resetsAtThe 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.

RouteReturns
GET /conversations/:id/goalA 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:

FieldDescription
id, agentId, conversationIdIDs
createdByuser or agent
objectiveThe text of the goal
statusactive, waiting, paused, blocked, limit_reached, completed, or cancelled. Show plain "Goal" for a value that you do not know.
activityqueued, working, verifying, stopping, or null
reasonnull, or why the goal waits, is blocked, or stopped: code, message (one sentence to show), wakeAt, repairAction
pausedByuser, agent, or null. The agent can resume only a goal that it paused.
controlRevisionIncreases on each control. Send it as expectedControlRevision.
criteriaThe 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.
limitsmaxIterations, maxDurationMs, maxProviderRequests, maxTokens (null is no token limit)
usageProvider requests, tokens, and usdEstimate (null when the model has no price)
iteration, nextAction, latestCheckpointProgress

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.typeOther fields
createobjective, optional limits, optional images. Send goalId and expectedControlRevision as null.
pause, resume, clearNone
editobjective
limitslimits: 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.

Statusdetails.reason
400validation_failed
403forbidden. For example, only the user can clear or edit a goal.
404goal_not_found (code not_found). A 404 with no details means that the conversation does not exist.
409goal_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.

RouteResult
GET /agents/:id/routines{ items }: routines and wake-ups, newest first
POST /agents/:id/routines201 with the new routine (createdBy: "user")
GET /routines/:routineIdOne routine
PATCH /routines/:routineId200 with the changed routine
DELETE /routines/:routineId204, no body. Deletes the routine or cancels the wake-up. Its run history stays.
POST /routines/:routineId/run202 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.kindOther fields
daily, weekdaysat (local HH:MM), timezone (IANA name)
weeklyat, days (ISO weekdays, 1 is Monday), timezone
intervaleveryMinutes, from 15 to 10080
onceat, an RFC 3339 time
croncron, 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.

FieldValues
stateactive, paused, expired, or ended
pausedReasonOnly while state is paused: failures (three failed runs in a row), user, or agent
endedReasonOnly 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.

FieldValues
statusrunning, ran, quiet, skipped, or failed
startedAtWhen the turn of the run started. null while the run waits to start, and on a skip.
waitingReasonOnly 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.
skipReasonbudget, 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:

Statusdetails.reasonWhen
400validation_failedA body or query that is not valid, or a schedule under 15 minutes
404routine_not_found (code not_found)No such routine. A 404 with no details means that the agent does not exist.
409limit_reachedMore than 20 active routines for the agent, or more than 100 in the HQ. A resume counts as a create.
409routine_endedThe routine has ended or expired

Squad message threads

Agents send messages to each other in threads.

RouteResult
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/pauseThe thread, with state: "paused" and pausedReason: "user"
POST /threads/:threadId/resumeThe open thread. It lets the agents send 12 more messages.
POST /threads/:threadId/closeThe 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": {}
    }
  ]
}
kindThe item
questionA question card waits for an answer. ref.toolCallId
goalA goal waits for input or is blocked. ref.goalId
limitThe agent reached its daily limit
threadA squad thread is paused. ref.threadId
routineA 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:

  1. If the upgrade has an Authorization header that is not empty, the header must be Bearer <mobile-token>. The HQ looks at nothing else.
  2. If not, the HQ accepts a ?token=<mobile-token> query parameter or a ?ticket=<ticket> from POST /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" }
FieldTypeDescription
ticketstringRandom ticket. The HQ makes 32 random bytes as 64 lowercase hex characters.
expiresAtstringISO 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 Authorization header. 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:

  1. Call GET /mobile/v1/health. Make sure that it has contractVersion: 1.
  2. Call GET /mobile/v1/identity with 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.

RouteResult
GET /conversations/:id/bootstrapThe conversation, messages, pending inputs, queue state, and throughSeq
GET /conversations/:id/messagesMessages 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 /eventsConversation 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_subscribed contains the replay watermark (throughSeq).
  • The subscription stays open across runs. A terminal frame does not close it.
  • To connect again, send the last seq that you applied.
  • A first frame other than hello closes the socket with 1002 hello_required.
  • The HQ closes the socket with 1002 after a bad negotiation frame or protocol frame.
  • If the HQ rejects a command, it sends command_rejected and 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.

CommandNecessary fieldsBehavior
messageAgent, channel, conversation, text, resumable: trueStart a run when the conversation is free
enqueue_inputAgent, channel, conversation, inputId, text, behaviorsteer guides the active run. followUp queues a later run. auto lets the HQ choose.
edit_follow_upConversation, input, expectedRevision, textChange an item that did not start. New images replace all of its images.
remove_follow_upConversation, input, expectedRevisionRemove a queued item
resume_follow_upsConversation, expectedQueueRevisionResume a paused queue
cancelid of the active runStop that run. If Follow Ups wait, the HQ pauses the queue.
answerRun ID, questionId, answerAnswer 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:

FrameMeaning
input_acceptedThe HQ accepted an enqueued input
input_updated, input_removedAn edit or a removal is complete
input_deliveredLinks the input to its run, segment, and message IDs
input_failedThe input failed
queue_paused, queue_resumedThe queue state changed
command_rejectedThe 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:

  1. Show the Steer and Follow Up choice, with details.suggested selected.
  2. Send the choice of the user as a new enqueue_input with an explicit behavior.
  3. Use a new id and a new inputId. The rejected command made no pending row.
  4. 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, or not_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.
  • cancel stops the active run. If Follow Ups wait, the HQ also pauses the queue and sends queue_paused. No Follow Up starts until the client sends resume_follow_ups.

Limits

LimitValue
Pending Follow Ups for each conversation20
Pending Steers for each conversation20
Total pending payload for each conversation100 MiB
Images for each input4 (JPEG, PNG, GIF, or WebP)
Size of one image5 MiB
Total decoded image data for each input12 MiB

The HQ rejects a send or an edit that goes over these limits.

On this page

OverviewListeners and base URLsPairing payloadAuthenticationCORS and browser originsError envelopeCompatibility rulesClient capabilitiesRoute indexHQ health and identityGET /healthGET /identityAgentsGET /agentsPOST /agentsGET /agents/:idPUT /agents/:idDELETE /agents/:idPOST /agents/:id/enablePOST /agents/:id/disableGET /agents/:id/skillsMemoryGET /agents/:id/memoryGET /agents/:id/memory/:nameDELETE /agents/:id/memory/:nameSub-agent definitionsGET /agents/:id/subagent-typesGET /agents/:id/subagent-definitionsGET /agents/:id/subagent-definitions/:namePUT /agents/:id/subagent-definitions/:nameDELETE /agents/:id/subagent-definitions/:nameModelsGET /modelsConversationsThe conversation objectETag and If-MatchDelete and archivePOST /conversations/:id/archivePOST /conversations/:id/restoreQuery parameter rulesGET /conversationsPOST /conversationsGET /conversations/:idPATCH /conversations/:idDELETE /conversations/:idGET /conversations/:id/messagesGET /conversations/:id/bootstrapSub-agentsGET /conversations/:id/subagentsPOST /subagents/:id/stopPOST /subagents/:id/resumeReplay and eventsGET /agents/:agentId/conversations/:conversationId/eventsGET /eventsAI providersGET /providersKeysSubscription sign-inSpeechGET /speech/configPATCH /speech/configGET /speech/modelsPOST /speech/transcriptionsPOST /speech/speechLong-horizon workGET /agents/:id/autonomyPATCH /agents/:id/autonomyGoalsRoutines and wake-upsSquad message threadsGET /needs-youChat WebSocketPOST /ws-ticketSteer and Follow UpSnapshot and replaySubscribe before you sendCommands and durable acknowledgementsDelivery rulesLimits