/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.
Authentication
Every route exceptGET /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-tokenflag. 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
Bearerfollowed by the token exactly. There is no trimming and no case folding. - Both token fields of a pairing payload,
mgmtTokenandchatToken, 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 aschatTokenfrom the control plane. See Relay Control Plane API. - If the HQ has a management token but no mobile token, every
/mobile/v1route except/healthreturns401. If it has neither, authentication is off. That only happens on a loopback-only gateway.
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.
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, andDELETE /agents/:id/memory/:name. - The
404fromGET /agents/:id/skills.
Compatibility rules
- Nullable fields are always sent as explicit JSON
null. Omitting a field and sendingnullmean different things. Optional fields, such asdeletedAt, 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.401.
POST /agents
Creates an agent. The body accepts exactly these three fields, all required: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 ofmodel and systemPrompt. No other field is accepted.
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 tostatus: "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.
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, withcontent (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 theMobileApiError 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’ssubagents.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.
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 itsETag. 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. RequiresIf-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 newETag. 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 (ascendingordinal). 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, withkind: "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’ssend_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 withseq 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.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 thespeech-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 asGET /speech/config.
The merge is shallow per section: an omitted key keeps its current value.
sttacceptsprovider,model, andlanguage. Setlanguagetonullto clear it back to the provider’s own detection:{ "stt": { "language": null } }.ttsacceptsprovider,model,voice, andspeed.realtime, when present, must includeprovider, which may benull. Omitting the key inside a presentrealtimeobject is a400.- An unknown key anywhere in the body is a
400, not a silent drop.
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.
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:
- If an
Authorizationheader is present and non-empty, it must beBearer <mobile-token>. Nothing else is considered. - Otherwise, a
?token=<mobile-token>query parameter, or a?ticket=<ticket>fromPOST /ws-ticket.
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
Authorizationheader. 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.
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.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:
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-generatedid. 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.