Skip to main content
The Mobile API is the versioned HTTP surface that native and browser clients use to talk to an HQ. Every route lives under /mobile/v1. It covers the gateway’s health and identity, agents, conversations and their history, sub-agents, speech, and a Server-Sent Events stream of cache invalidations. Live chat runs on a separate WebSocket, described in HQ Chat WebSocket. The loopback-only administrative routes that Mission Control uses are documented in the Management API. Those routes use a different bearer and are never reachable from the LAN listener or the relay.

Overview

Listeners and base URLs

The same /mobile/v1 handlers are reachable three ways. Each one runs the same authentication and returns the same bodies. The LAN listener starts only when the HQ has both a management token and a separate mobile token. Mission Control always supplies both. A gateway started without them stays loopback-only. Certificate pinning. The LAN certificate is self-signed. Clients trust it by pinning its SHA-256 fingerprint, which the HQ reports as lowercase hex. The fingerprint comes from the pairing QR code (tlsCertificateSha256 in the version 3 pairing payload). The HQ does not serve it on the Mobile API. In a version 3 payload mgmtPort and chatPort must be equal, because one listener serves both REST and the chat WebSocket.
A relay pairing (version 2) carries the relay host and a per-device relay credential in place of the port and fingerprint:
Clients must reject the legacy plaintext version 1 payload.

Authentication

Every route except GET /mobile/v1/health needs the HQ’s mobile bearer:
  • The mobile bearer is the HQ’s phone-scoped token, set with the gateway’s --chat-token flag. It is deliberately distinct from the management --token: the HQ refuses to start if the two are equal. The management token is not accepted on /mobile/v1, and the mobile token is not accepted on any administrative route.
  • The header must equal Bearer followed by the token exactly. There is no trimming and no case folding.
  • Both token fields of a pairing payload, mgmtToken and chatToken, carry this same bearer. Clients trim both, require them to be equal, and store one value. Mission Control writes the bearer into the pairing QR code. Clients that pair through a DashSquad account receive it as chatToken from the control plane. See Relay Control Plane API.
  • If the HQ has a management token but no mobile token, every /mobile/v1 route except /health returns 401. If it has neither, authentication is off. That only happens on a loopback-only gateway.
A missing or wrong bearer gets:
Through the relay, also send the per-device relay credential on every request:
The relay checks the credential and removes it. The HQ never sees it. The bearer is forwarded unchanged and checked by the HQ. A relay-level rejection is a text/plain body from the relay, not the JSON envelope below. See Relay API. On the LAN, send the bearer only, over a TLS connection whose leaf certificate matches the pinned fingerprint.

CORS and browser origins

Cross-origin access is off unless the HQ has a web-origin allowlist. The allowlist is resolved at startup: When the allowlist is non-empty, the HQ applies these CORS rules to /mobile/v1 and nothing else: Preflight OPTIONS requests are answered before authentication, because browsers never attach a bearer to a preflight. x-dash-relay-credential is in the allowed headers because a browser must get it preflight-approved before the relay will see it.
PUT is not an allowed method and If-Match is not an allowed header. A browser client therefore cannot call PUT /agents/:id, PUT /agents/:id/subagent-definitions/:name, or the conditional PATCH and DELETE /conversations/:id cross-origin. ETag is not exposed to scripts either. The same revision is always in the response body.

Error envelope

Errors use one JSON envelope, MobileApiError:
Two families of routes return a bare { "error": "<message>" } body instead of the envelope. The body has no code or retryable, so map these by HTTP status:
  • The memory routes: GET /agents/:id/memory, GET /agents/:id/memory/:name, and DELETE /agents/:id/memory/:name.
  • The 404 from GET /agents/:id/skills.

Compatibility rules

  • Nullable fields are always sent as explicit JSON null. Omitting a field and sending null mean different things. Optional fields, such as deletedAt, may be omitted.
  • Cursors are opaque strings. Send them back unchanged and don’t derive ordering from them.
  • Agent IDs are opaque non-empty strings. A user conversation ID is a UUID. A sub-agent conversation ID is sub_ followed by a 26-character ULID. Both work anywhere a conversation ID is accepted.
  • Request bodies reject unknown fields.
  • Ignore unfamiliar agent event types and properties, capability strings, and SSE event names. Don’t reject the containing message.

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.

Gateway

GET /health

Returns the HQ’s health, the Mobile API version, and the capabilities it supports. This is the only unauthenticated route. Call it first to check that the HQ speaks the features you need.
Ignore capability strings you don’t recognize. A client that rejects an unknown capability breaks as soon as the HQ adds one.

GET /identity

Returns the HQ’s stable identity: its relay gateway ID and its Ed25519 public key.
Errors: 401.

Agents

Agent routes return the agent’s stored record with provider API keys removed. Mobile clients can create an agent and change its model and system prompt. Every other setting is managed in Mission Control. The agent object: config always has name, model (a provider/model string), and systemPrompt. It may also carry fallbackModels, tools, skills (paths, urls), workspace, maxTokens, mcpServers, plugins, providers, and swarm. providerApiKeys is never returned. Treat any other configuration key as informational and tolerate it. Failures on the agent routes use the envelope. 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 every registered agent.
Errors: 401.

POST /agents

Creates an agent. The body accepts exactly these three fields, all required:
Returns 201 with the new agent object.

GET /agents/:id

Returns one agent object. Errors: 401, 404.

PUT /agents/:id

Updates an agent. Send at least one of model and systemPrompt. No other field is accepted.
Returns 200 with the updated agent object.

DELETE /agents/:id

Deletes an agent. Its running work is cancelled and its conversations are archived: each one moves to status: "archived" and a conversation:changed event is sent for it. Archived conversations stay readable but can no longer be updated, deleted, or given new turns.
Errors: 401, 404, 500.

POST /agents/:id/enable

POST /agents/:id/disable

Enable or disable an agent. Both take no body and return { "ok": true }. Errors: 401, 404, 500.

GET /agents/:id/skills

Read-only list of the skills the agent can load, including skills it learned by itself. Mobile clients can’t create, install, edit, or remove skills.
Errors: 401. 404 when the agent does not exist, with the bare body { "error": "not found" }.

Memory

An agent’s saved memories. Mobile clients can list, read, and delete memories. Writing memories and changing memory settings are Mission Control only.
These three routes return a bare { "error": "<message>" } body on every failure, not the MobileApiError envelope. A 404 means the agent or the named memory does not exist.

GET /agents/:id/memory

Lists the agent’s memories. Returns an empty array for an agent with no memory directory.
Errors: 401, 404 (unknown agent).

GET /agents/:id/memory/:name

Returns one memory. The record has the same fields as a list entry, with 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:

Sub-agent definitions

Sub-agent types an agent can spawn, and the definition files it owns. These routes use the MobileApiError envelope. A missing agent or definition is 404 not_found. The routes are mounted only when the HQ has a sub-agent definition registry. Without it they return 404.

GET /agents/:id/subagent-types

The resolved roster of sub-agent types this agent can spawn. It merges built-in types, plugin definitions, the agent’s own definition directory, and the workspace directories by precedence, then narrows the result by the agent’s subagents.allowedTypes. A definition that lost a name collision is listed right after the winner, with shadowedBy set. The full definition body is not included. Fetch it from the definition route when you need it. Each type has name, description, and source (builtin, workspace, agent, or plugin). It may also have location, tools, disallowedTools, model, skills, maxTurns, background, isolation ("worktree"), and shadowedBy (the winning definition’s file path, or its source when it has no file). Errors: 401, 404.

GET /agents/:id/subagent-definitions

Lists the .md files in the agent’s own definition directory, sorted by file name. This is the only writable layer. Workspace and plugin definitions are read-only and appear only in the roster. Returns an empty list when the directory does not exist.
Errors: 401, 404.

GET /agents/:id/subagent-definitions/:name

Returns a definition’s raw Markdown, byte for byte:
:name is the file name without .md. It must match ^[a-z0-9][a-z0-9-]*$.

PUT /agents/:id/subagent-definitions/:name

Creates or replaces <name>.md in the agent’s definition directory.
The body is parsed before anything is written. The frontmatter name must equal :name. On success the agent’s roster is refreshed and the route returns:

DELETE /agents/:id/subagent-definitions/:name

Deletes the definition file and refreshes the roster. Returns { "ok": true, "name": "<name>" }. Errors: 400 (bad :name), 401, 404 (no such agent or definition).

Models

GET /models

Lists the models available when configuring an agent. The list comes from the HQ’s model cache, which is refreshed from the providers when it is stale.
The Mobile API takes no query parameters here. Any 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

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:
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:

Deletion and archiving

Deleting a conversation purges its messages, event log, pending inputs, and queued notifications, then keeps the row as a tombstone: status: "deleted", a new revision, and deletedAt. Every sub-agent conversation beneath it is tombstoned too, even one that is still running. GET /conversations/:id still returns the tombstone. A further PATCH or DELETE returns 410 with code not_found and retryable: false. Archiving happens when the owning agent is deleted. The conversation moves to status: "archived" and stays readable, but PATCH and DELETE return 409 validation_failed, and new turns are refused.

Query parameter rules

The three list routes (GET /conversations, /messages, and /pending) reject any query parameter they don’t define, a parameter given more than once, and an empty value, all with 400 validation_failed. limit must be a positive integer no larger than the route’s maximum. A cursor on /conversations or /messages that the HQ can’t decode is 400 Invalid pagination cursor. The /pending cursor is a pending item ID: an unknown ID restarts the listing from the first item.

GET /conversations

Lists conversations, most recently updated first. Deleted conversations and sub-agent conversations are left out. Archived conversations are included.
nextCursor is null on the last page. Errors: 400, 401.

POST /conversations

Creates a conversation. requestId makes the call idempotent: repeating a request with the same requestId returns the conversation that request first created, instead of making a new one.
Every string must be non-blank and is trimmed. Returns 201 with the conversation object and its ETag, including when an earlier request with the same requestId already created it. A conversation:changed event is sent on /events.

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 links. Requires If-Match. Send at least one field.
Returns the updated conversation and its new ETag, and sends conversation:changed.

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: A 401 is returned before any of these when the bearer is wrong.

GET /conversations/:id/messages

Pages through the conversation’s stored messages, newest page first. Items within a page are in chronological order (ascending ordinal). Pass nextCursor as before to get the page of older messages.
Message content: Errors: 400, 401, 404. A deleted conversation returns 404 here.

GET /conversations/:id/pending

Pages through the conversation’s queued inputs, the Follow Up and priority messages waiting for the current turn to finish. Priority items come before follow-ups. A conversation holds at most 100 pending inputs.
Items 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 a conversation’s agent starts. Each child runs in its own conversation, with kind: "subagent" and an ID of the form sub_<ulid>. That ID is also the sub-agent’s ID in the routes below. These routes use the MobileApiError envelope. They are mounted only when the HQ runs the sub-agent coordinator. Without it they return 404. A sub-agent conversation’s subagent field describes the child:
status is one of running, waiting_input, done, failed, cancelled, interrupted, or max_turns. The last five are terminal. After an HQ restart every unfinished child is marked interrupted. The object may also carry endedAt, report, and workspace.

GET /conversations/:id/subagents

Lists a conversation’s sub-agents, oldest first, with enough detail to render a tasks panel without opening each child. report is scanned before it leaves the HQ: control tags are neutralized and role prefixes escaped, because clients render it directly.
The route returns at most the 100 newest children. Errors: 401, 404 (no such conversation).

POST /subagents/:id/stop

Cancels the sub-agent and every descendant beneath it, leaves first. Takes no body.
status is the child’s terminal status afterward. It can differ from cancelled if the child finished on its own while the stop was in progress.

POST /subagents/:id/resume

Sends a message to the sub-agent as its parent would, with the same effect as the agent’s send_message tool. A running child queues the message as its next turn. A finished child, including one interrupted by an HQ restart, is resumed on its own conversation.

Replay and events

GET /agents/:agentId/conversations/:conversationId/events

Returns the conversation’s event-log entries with seq greater than sinceSeq, in seq order. Use it to catch up after a dropped chat connection, starting from the last seq you saw or from throughSeq on a messages page.
msgId is the turn ID. payload is one of: A deleted conversation returns { "entries": [] }. So does an unknown conversation ID under an agent that exists.

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.
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:
The same stream also carries the HQ’s other change events, such as agent, channel, plugin, and swarm updates. Ignore any event name you don’t handle. A : keepalive comment line is sent every 30 seconds. Events have no id: field, so the stream can’t be resumed with Last-Event-ID. After reconnecting, re-list conversations and compare revisions. Through the relay, an open stream counts against the HQ’s concurrent-stream cap. See Relay API. Errors: 401.

Speech

Transcription and speech synthesis through the HQ’s configured speech provider. Check for the speech-v1 capability in GET /health before calling these routes. When the HQ has no speech service at all, the /speech routes are not mounted and return 404. Speech routes use the MobileApiError envelope. Provider failures pass their own codes through unchanged: Every body-bearing speech route checks Content-Length before reading the body and returns 413 too_large (request body too large) when it is over the limit: 12 MB for POST /speech/transcriptions, 64 KiB for PATCH /speech/config and POST /speech/speech.

GET /speech/config

Returns the HQ’s speech configuration and each provider’s availability.
Errors: 401.

PATCH /speech/config

Merges a partial configuration and returns the result in the same shape as GET /speech/config. The merge is shallow per section: an omitted key keeps its current value.
  • stt accepts provider, model, and language. Set language to null to clear it back to the provider’s own detection: { "stt": { "language": null } }.
  • tts accepts provider, model, voice, and speed.
  • realtime, when present, must include provider, which may be null. Omitting the key inside a present realtime object is a 400.
  • An unknown key anywhere in the body is a 400, not a silent drop.
Errors: 400, 401, 413.

GET /speech/models

Lists the configured provider’s models for one kind of speech. Results are cached for an hour per provider.
voices is present only on speech models that have named voices. Errors: 400, 401, 502, 503.

POST /speech/transcriptions

Transcribes one recorded clip. The audio is sent as base64 inside JSON, not as multipart.
The clip must also be at most 60 seconds long.
durationSeconds is present only when the provider reports it. Errors: 400, 401, 413, 502, 503.

POST /speech/speech

Synthesizes speech from text. This is the only Mobile API route whose success body is not JSON.
Send Accept: audio/mpeg, audio/wav. The response is one of:
  • Content-Type: audio/mpeg, streamed as the provider produces it.
  • Content-Type: audio/wav, sent whole, when the configured model only produces raw PCM.
An error raised before the first audio chunk is returned as a JSON MobileApiError, so be ready to decode JSON on this route too. Errors: 400, 401, 413, 502, 503.

Chat WebSocket

Live turns, steering, Follow Up, and voice run over the chat WebSocket at /ws/chat, on the same host as the Mobile API: the LAN listener or the relay hostname. The frame protocol is documented in HQ Chat WebSocket. This section covers only connecting. The socket authenticates with the same mobile bearer. The HQ checks, in order:
  1. If an Authorization header is present and non-empty, it must be Bearer <mobile-token>. Nothing else is considered.
  2. Otherwise, a ?token=<mobile-token> query parameter, or a ?ticket=<ticket> from POST /ws-ticket.
On failure the upgrade completes and the HQ closes the socket with code 4001 Unauthorized. Native clients should send the Authorization header. Browsers can’t set headers on a WebSocket upgrade, so they mint a ticket over HTTP and pass it in the URL, which keeps the long-lived bearer out of URLs and logs. Through the relay, browsers pass the relay credential as a WebSocket subprotocol. See Relay API.

POST /ws-ticket

Mints a single-use ticket for one WebSocket upgrade. Takes no body.
Use it straight away:
  • A ticket works once. Redeeming it removes it, whether or not it had expired.
  • It is honored only when the upgrade carries no Authorization header. If a header is present, the header alone decides and the ticket is left unused.
  • One ticket store serves the whole HQ, so a ticket minted through the relay also works on the LAN listener and the other way round.
Errors: 401.

Steer and Follow Up (Mobile API v2)

The v2 API ships with the steer-and-follow-up release. Until that release reaches your HQ, /mobile/v2 routes return 404 and the v1 surface described on the Mobile API page is the only one available.
Current clients probe GET /mobile/v2/health and require the chat-input-queue-v1 capability. Verify /mobile/v2/identity with the phone-scoped Mobile bearer before opening conversations. The direct mobile listener and hosted relay expose both /mobile/v1 and /mobile/v2. V2 uses the same Mobile bearer and, through the relay, the same per-device relay credential. Administrative management bearers are not accepted by either mobile namespace. V1 remains available with its existing DTOs, cursor, and pending-work commands. Select v1 only when the v2 health resource is explicitly unavailable. Do not downgrade after a rejected credential, wrong HQ identity, malformed response, or TLS verification failure. A Desktop local conversation retains its existing connection-owned behavior.

Snapshot and replay

Paths in this table are relative to /mobile/v2 and require the Mobile bearer: The management-auth equivalents use /conversations/:id/bootstrap, /conversations/:id/messages-v2, and /agents/:agentId/conversations/:conversationId/events-v2?sinceV2Seq=N on the loopback management server. The older unprefixed message and event routes keep their v1 format. V2’s v2Seq and v1’s seq are independent, dense sequences. Never send sinceSeq to the v2 replay route or infer a v2 cursor from a v1 frame. Store a v2 cursor only after applying its frame. On compaction, read a fresh bootstrap and preserve already loaded history by canonical message ID.

Subscribe before sending

Connect to /ws/chat using the existing authenticated native socket or a single-use ticket. The first frame selects v2:
Wait for hello_ack, then subscribe from the bootstrap’s watermark:
conversation_subscribed includes the replay watermark. The subscription lasts across runs; a terminal frame does not close it. Reconnect with the last applied v2Seq. A socket that has not negotiated v2 retains the v1 protocol. Malformed negotiation or protocol frames close with 1002; application command rejection uses command_rejected without closing a healthy subscription.

Commands and durable acknowledgements

Every command has a client-generated id. An enqueued input also has a distinct inputId. Reuse both identities and exactly the same payload when retrying an uncertain command. Reusing an ID for a different payload is rejected. A normal message.id remains the outer run identity. Steer additionally requires expectedActiveTurnId. It cannot silently target a different run. A successful enqueue emits input_accepted; edits and removals emit input_updated and input_removed. input_delivered links the accepted input to its run, segment, and message IDs. queue_paused and queue_resumed publish queue state. Rejections preserve the submitted draft; a stale item revision requires a refresh before another edit or removal. A Steer is delivered only at a safe agent boundary. Its permanent user row records pending, delivered, or not_delivered; delivered rows are labeled Steered. Each delivered Steer starts a new assistant segment inside the same outer run. A late Steer never becomes a future Follow Up. Follow Ups run in order, one at a time. Failure pauses the remaining queue until Resume Follow Ups. The v1 compatibility command stop_conversation retains its older Stop-pauses behavior, and interrupt_and_send retains priority interruption; v2 cancel advances the queue. V2 permits 20 pending Follow Ups and 20 pending Steers per conversation, with a combined pending payload limit of 100 MiB. Each input accepts up to four JPEG, PNG, GIF, or WebP images: 5 MiB per image and 12 MiB combined decoded image data. Sending or editing beyond these limits is rejected.