> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dashsquad.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Management API

> HTTP API for monitoring and controlling a running Dash HQ.

The Management API lets you monitor and control the HQ programmatically. It's the same API
Desktop uses to deploy agents, connect messaging apps, and store credentials. Its
unprefixed administrative surface binds to `127.0.0.1` on port `9300` by default.

Native clients do not receive that administrative access. On a local network they use the
HQ's pinned HTTPS/WSS listener on the configured mobile port, which defaults to `9400`.
Both frozen pairing port fields carry that same listener port. The listener exposes only
`/mobile/v1` and `/ws/chat`. Paths in the **Conversations** section are relative to `/mobile/v1`.
A relay exposes the same native surface over TLS on port `443`.

## Authentication

The health resource — `GET /health`, also available as `GET /mobile/v1/health` — is the only
management resource that does not require authentication.

Desktop loopback clients continue to use the management Bearer for unprefixed management routes
and the separate chat token accepted by the loopback channel server on port `9200`:

```text theme={null}
Authorization: Bearer <your-management-token>
ws://localhost:9200/ws/chat?token=<your-chat-token>
```

Native clients instead receive one phone-scoped Mobile bearer. They send it in the
`Authorization` header for every authenticated `/mobile/v1` HTTP or SSE request and for the
`/ws/chat` WebSocket upgrade:

```text theme={null}
Authorization: Bearer <your-mobile-token>
```

Native WebSocket clients send that same bearer in the `Authorization` header, never in the URL.
When a request travels through the hosted relay, the device additionally sends
`x-dash-relay-credential`. The relay credential authorizes that device at the relay; it does not
replace the Mobile bearer enforced by the HQ.

## Health

### GET /health

Returns HQ status and supported mobile capabilities. No authentication is required. The same
response is available from `GET /mobile/v1/health`.

```bash theme={null}
curl http://localhost:9300/health
```

**Response**

```json theme={null}
{
  "status": "healthy",
  "startedAt": "2026-07-12T00:00:00.000Z",
  "pid": 12345,
  "agents": 2,
  "channels": 1,
  "apiVersion": 1,
  "capabilities": ["conversation-sync-v1", "chat-resume-v1"]
}
```

| Field          | Type        | Description                                         |
| -------------- | ----------- | --------------------------------------------------- |
| `status`       | `"healthy"` | Always `"healthy"` while the HQ is serving requests |
| `startedAt`    | `string`    | ISO timestamp for this HQ process                   |
| `pid`          | `number`    | HQ process ID                                       |
| `agents`       | `number`    | Number of registered agents                         |
| `channels`     | `number`    | Number of configured messaging channels             |
| `apiVersion`   | `1`         | Mobile API version                                  |
| `capabilities` | `string[]`  | Features the client may use                         |

Use `conversation-sync-v1` to enable HQ-authoritative conversation lists and messages. Use
`chat-resume-v1` to enable durable sequences, replay, and resumable WebSocket turns.

## Runtime status

### GET /runtime/status

Returns a current operational snapshot on the loopback Management API. Requires the management
Bearer token. This endpoint is unavailable through `/mobile/v1`, the mobile LAN listener, or the
hosted relay.

```bash theme={null}
curl http://localhost:9300/runtime/status \
  -H "Authorization: Bearer <your-management-token>"
```

| Field                            | Description                                                                     |
| -------------------------------- | ------------------------------------------------------------------------------- |
| `execution.accepting`            | Whether execution is accepting new work                                         |
| `execution.activeCanonicalTurns` | Active conversation turns, including turns still cleaning up after cancellation |
| `execution.activeLegacyTurns`    | Active messaging-channel or legacy turns, including cancellation cleanup        |
| `execution.quiescingAgents`      | Number of agents fenced from admitting new turns                                |
| `pool`                           | Current runtime count, capacity, pinned runtime count, and counts by agent ID   |
| `channels`                       | Running channel names and each adapter's reported connection health             |
| `relay.connection`               | `disabled`, `connecting`, `connected`, `disconnected`, or `stopped`             |
| `relay.activeStreams`            | Number of streams currently tracked by the relay client                         |

`GET /health` remains the lightweight liveness check. Runtime status reports process state;
it does not test provider availability or imply that queued work has finished. It contains no
credentials, conversation text, or pending-message contents.

## Identity

### GET /identity

Returns the HQ's stable ID and Ed25519 public key. Native clients call
`GET /mobile/v1/identity` with their phone-scoped Mobile bearer. The unprefixed loopback
`GET /identity` alias requires the administrative management bearer instead.

```bash theme={null}
curl "$MOBILE_URL/mobile/v1/identity" \
  -H "Authorization: Bearer $MOBILE_TOKEN"
```

```json theme={null}
{
  "gatewayId": "gateway-01",
  "publicKey": "dash-gateway-public-key"
}
```

The public key identifies the HQ and is safe to return to clients. The corresponding private
key never leaves the HQ machine.

## Agents

Create and manage agents. New and updated agents are persisted to
`~/.dash/gateway/agents.json`.

| Method   | Path                  | Purpose                                              |
| -------- | --------------------- | ---------------------------------------------------- |
| `GET`    | `/agents`             | List all agents                                      |
| `POST`   | `/agents`             | Create an agent                                      |
| `GET`    | `/agents/:id`         | Get one agent                                        |
| `PUT`    | `/agents/:id`         | Update an agent's configuration                      |
| `DELETE` | `/agents/:id`         | Delete an agent and archive its conversation history |
| `POST`   | `/agents/:id/enable`  | Enable a disabled agent                              |
| `POST`   | `/agents/:id/disable` | Disable an agent without deleting it                 |

Native clients use these routes beneath `/mobile/v1`. Mobile create accepts `name`, `model`, and
`systemPrompt`; mobile update accepts `model` and/or `systemPrompt`. Agent responses never include
provider API keys. The unprefixed routes remain available for Desktop and support its full
configuration fields.

Agent skills are managed under the unprefixed agent routes — `GET/POST /agents/:id/skills`,
`GET/PUT/DELETE /agents/:id/skills/:name`, `POST /agents/:id/skills/install`, and
`GET/PATCH /agents/:id/skills/config`. See [Skills](/skills).

**Example: create a mobile agent**

```bash theme={null}
curl -X POST "$MOBILE_URL/mobile/v1/agents" \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "assistant",
    "model": "anthropic/claude-sonnet-4-20250514",
    "systemPrompt": "You are a helpful assistant."
  }'
```

## Conversations

The HQ is the authority for capable clients' conversation metadata, messages, active turn,
revision, and replay sequence. All routes below use the `/mobile/v1` native base and require the
phone-scoped Mobile bearer.

| Route                                                       | Purpose                                                                               |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `GET /conversations`                                        | List conversation summaries; accepts `agentId`, `limit`, and opaque `cursor`          |
| `POST /conversations`                                       | Create a conversation idempotently with `agentId` and `requestId`                     |
| `GET /conversations/:id`                                    | Read the current summary, including archived or deleted state                         |
| `PATCH /conversations/:id`                                  | Change title, owning issue, or project using `If-Match`                               |
| `DELETE /conversations/:id`                                 | Purge content and return a revisioned tombstone using `If-Match`                      |
| `GET /conversations/:id/messages`                           | Read persisted messages; accepts `limit` and opaque `before`                          |
| `GET /agents/:agentId/conversations/:conversationId/events` | Replay entries after `sinceSeq`                                                       |
| `GET /events`                                               | Stream `conversation:changed` and `conversation:deleted` cache invalidations over SSE |

Conversation pages default to 50 items in newest-updated order. Pass `nextCursor` back unchanged
as `cursor`; cursors are opaque. Message pages default to 100 items. They select the newest page,
return that page in chronological order, and paginate backward by passing `nextCursor` as
`before`. `throughSeq` tells the client which durable sequence the page includes through.

### Create and read a conversation

```bash theme={null}
curl -i -X POST "$MOBILE_URL/mobile/v1/conversations" \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "agent-01",
    "requestId": "create-request-01",
    "title": "Release planning"
  }'
```

Creation returns HTTP `201`, a `ConversationSummary`, and an ETag containing the quoted revision:

```text theme={null}
ETag: "1"
```

Reusing the same `requestId` returns the original conversation instead of creating a duplicate.
`GET /conversations/:id` also returns the current ETag.

### Update with ETag and If-Match

Pass the revision you last read in a quoted `If-Match` header:

```bash theme={null}
curl -i -X PATCH "$MOBILE_URL/mobile/v1/conversations/$CONVERSATION_ID" \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "1"' \
  -d '{"title":"Launch checklist"}'
```

A successful change increments the revision and returns a new ETag. If another client changed the
conversation first, the HQ returns HTTP `409` with `revision_conflict`. Replace the cached
summary with `details.current`, then retry deliberately with that current revision.

```json theme={null}
{
  "code": "revision_conflict",
  "error": "Conversation revision 1 is stale",
  "retryable": false,
  "details": {
    "current": {
      "id": "018f0f4a-5c42-7a8b-9c01-1234567890ab",
      "agentId": "agent-01",
      "agentName": "assistant",
      "title": "Launch checklist",
      "revision": 2,
      "status": "idle",
      "activeTurnId": null,
      "owningIssueId": null,
      "projectId": null,
      "lastSeq": 0,
      "lastMessagePreview": null,
      "createdAt": "2026-07-12T00:00:00.000Z",
      "updatedAt": "2026-07-12T00:01:00.000Z"
    }
  }
}
```

If a turn is active, deleting returns HTTP `409` with `conversation_busy` and the owning
`activeTurnId`. Resume or cancel that turn before retrying with a freshly read revision.

```json theme={null}
{
  "code": "conversation_busy",
  "error": "Conversation has an active turn",
  "retryable": false,
  "details": { "activeTurnId": "018f0f4a-5c42-7a8b-9c01-2234567890ab" }
}
```

### Deletion and archived history

Successful `DELETE /conversations/:id` immediately removes its messages and replay entries, then
returns a summary whose `status` is `deleted` and whose revision has advanced. The HQ retains
that tombstone so `GET` remains deterministic. A later `PATCH` or repeated `DELETE` returns HTTP
`410` with a non-retryable `not_found` error.

Deleting an **agent** behaves differently: the HQ archives its conversations instead of
deleting them. Their saved summaries, messages, and replay entries remain readable through the
conversation routes even though the agent can no longer start a turn.

## Resumable chat

Desktop loopback clients use the channel server on port `9200`, including its browser-compatible
query-token fallback:

```text theme={null}
ws://localhost:9200/ws/chat?token=<your-chat-token>
```

Native clients connect to the same unprefixed path on the pinned mobile listener and keep the
Mobile bearer out of the URL. This example uses the default port; use the port from the pairing
payload when the HQ configured an override:

```text theme={null}
wss://<gateway-host>:9400/ws/chat
Authorization: Bearer <your-mobile-token>
```

For relay connections, use `wss://<relay-host>/ws/chat` on port `443` with the same
`Authorization` header plus `x-dash-relay-credential`.

Only use resumable frames after health advertises `chat-resume-v1`. A capable send includes
`resumable: true` and uses one stable turn ID:

```json theme={null}
{"type":"message","id":"00000000-0000-4000-8000-000000000002","agentId":"agent-01","channelId":"direct","conversationId":"00000000-0000-4000-8000-000000000001","text":"Hello","resumable":true}
```

The first durable server frame accepts the message and assigns its message IDs and sequence:

```json theme={null}
{"type":"accepted","id":"00000000-0000-4000-8000-000000000002","conversationId":"00000000-0000-4000-8000-000000000001","userMessageId":"00000000-0000-4000-8000-000000000003","assistantMessageId":"00000000-0000-4000-8000-000000000004","revision":2,"seq":1}
```

**Client frames**

| Type      | Required fields                                                           | Purpose                                          |
| --------- | ------------------------------------------------------------------------- | ------------------------------------------------ |
| `message` | `id`, `agentId`, `channelId`, `conversationId`, `text`, `resumable: true` | Start one durable turn                           |
| `resume`  | `id`, `agentId`, `conversationId`, `sinceSeq`                             | Replay missed frames and attach to the same turn |
| `answer`  | `id`, `questionId`, `answer`                                              | Answer a question emitted by the active turn     |
| `cancel`  | `id`                                                                      | Explicitly cancel the active resumable turn      |

**Server frames**

| Type       | Meaning                                                                                       |
| ---------- | --------------------------------------------------------------------------------------------- |
| `accepted` | User and assistant messages were created; includes `revision` and durable `seq`               |
| `event`    | One agent event such as a text delta, tool event, question, or response                       |
| `done`     | Durable terminal with `outcome: completed` or `outcome: cancelled`                            |
| `error`    | Durable terminal failure when `seq` is present; otherwise an unsequenced structured rejection |

Every server frame with a durable `seq` is persisted before it is broadcast. Admission errors sent
before a turn is accepted have no sequence and are not journaled or replayable. Durable sequences
are conversation-global and increase across turns. After a connection gap, call
`GET /agents/:agentId/conversations/:conversationId/events?sinceSeq=<last-seq>` on the versioned
management API, apply entries in ascending sequence order, then send `resume` with the **same** turn
ID and last durable sequence. Do not resend the message with a new turn ID.

Closing a socket only detaches that resumable subscriber; provider work continues at the HQ.
Send `cancel` to stop it. Messages without `resumable: true` retain the legacy connection-owned
behavior and are cancelled when their socket closes. Clients must preserve or safely ignore
unknown `event` variants so newer HQs remain compatible.

`message` also accepts an optional `modality: 'text' | 'voice'`. The HQ sets this to `'voice'`
only for the turn that its own hands-free voice session starts on the user's behalf (see
[Voice frames](#voice-frames) below) — it appends the spoken-mode system prompt for that turn only.
A dictated message is still ordinary typed text once transcribed: a client sending it as a `message`
frame must omit `modality` or send `'text'`, never `'voice'`.

### Voice frames

Hands-free voice mode adds ten frames (five client, five server) to the same `/ws/chat` socket
used for resumable chat, gated on the same `speech-v1` capability as the [Speech](#speech) routes.
`id` is the client-generated session id and is echoed on every `voice_*` frame in both directions.

**Client frames**

| Type           | Required fields                   | Purpose                                                                                       |
| -------------- | --------------------------------- | --------------------------------------------------------------------------------------------- |
| `voice_start`  | `id`, `agentId`, `conversationId` | Start a hands-free session on `conversationId`                                                |
| `voice_audio`  | `id`, `seq`, `pcm`                | One capture chunk: base64 PCM16, 16 kHz mono, at most 16384 decoded bytes (\~512ms) per frame |
| `voice_mute`   | `id`, `muted`                     | Mute or unmute the microphone; playback is unaffected                                         |
| `voice_stop`   | `id`                              | End the session from the client                                                               |
| `voice_played` | `id`, `seq`                       | Every `voice_speech` up to and including `seq` has finished **playing** on the client         |

**Server frames**

| Type               | Meaning                                                                                                                                                                                                 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `voice_state`      | The session's phase: `listening`, `transcribing`, `thinking`, `speaking`, `muted`, or `stopped`. The HQ never actually sends `stopped` — a session ends with a `voice_stopped { reason }` frame instead |
| `voice_transcript` | Text recognized from the user's speech. The transcript that starts a turn carries `final: true` and a `turnId`, and is always emitted before that turn's `accepted`                                     |
| `voice_speech`     | One chunk of the agent's spoken reply: `format: 'mp3'` (one clip per sentence) or `format: 'pcm16'` (raw chunks, with `sampleRate` set)                                                                 |
| `voice_error`      | A provider-level failure. `code` is the narrower `SpeechErrorCode` union: `unauthorized`, `unavailable`, `too_long`, `too_large`, `invalid`, `provider`, `network`                                      |
| `voice_stopped`    | The session ended, with `reason: 'client' \| 'socket' \| 'provider' \| 'replaced'`                                                                                                                      |

```json theme={null}
{"type":"voice_start","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","agentId":"agent-01","conversationId":"018f0f4a-5c42-7a8b-9c01-1234567890ab"}
```

```json theme={null}
{"type":"voice_speech","id":"018f0f4a-5c42-7a8b-9c01-6234567890ab","seq":3,"audio":"<base64>","format":"pcm16","sampleRate":24000,"text":"It's sunny and 24 degrees."}
```

Session rules:

* One voice session per socket. Sending `voice_start` while a session is already open stops the
  previous one with `voice_stopped { reason: 'replaced' }` before the new session starts.
* `voice_audio` sent before the HQ's own `voice_state listening` is silently dropped, not
  rejected — the phone begins streaming the instant it sends `voice_start`, before the HQ has
  confirmed the speech provider is available.
* The `voice_transcript` carrying the `turnId` that starts a turn is always emitted before the
  resumable hub's `accepted` for that turn, so a client can render the optimistic user row before
  the turn is confirmed.
* The HQ never sends `voice_state { state: 'stopped' }`, even though `stopped` is a valid
  enum value on the wire; every session end is a `voice_stopped { reason }` frame instead.
* `voice_audio.pcm` decodes to at most 16384 bytes (16 KiB) per frame; one byte over answers the
  ordinary `error` frame with `code: 'validation_failed'`, not `voice_error`.
* After the last `voice_speech` of a turn the HQ stays in `speaking` until the client sends
  `voice_played` with a `seq` at least as high as that chunk's, or until an 8-second safety timer
  fires. Clients that flush playback when the session leaves `speaking` must send it, or every
  reply's last sentence is cut off. A `voice_played` for audio a barge-in discarded should not be
  sent; a stale or out-of-order `seq` is ignored.

## Channels

Connect and manage messaging apps such as Telegram and WhatsApp. Channels are persisted to
`~/.dash/gateway/channels.json`.

| Method   | Path              | Purpose                                     |
| -------- | ----------------- | ------------------------------------------- |
| `GET`    | `/channels`       | List all channels                           |
| `POST`   | `/channels`       | Create a channel and connect its adapter    |
| `GET`    | `/channels/:name` | Get one channel                             |
| `PUT`    | `/channels/:name` | Update routing rules or access lists        |
| `DELETE` | `/channels/:name` | Remove a channel and disconnect its adapter |

See [Messaging Apps](/messaging-apps) for routing rules and access control.

## Credentials

Store provider API keys and messaging tokens in the HQ's encrypted store
(`~/.dash/gateway/credentials.enc`). See [Secrets](/secrets).

| Method   | Path                | Purpose                                              |
| -------- | ------------------- | ---------------------------------------------------- |
| `GET`    | `/credentials`      | List credential key names; values are never returned |
| `POST`   | `/credentials`      | Set one or more credentials                          |
| `DELETE` | `/credentials/:key` | Remove a credential                                  |

## Models

| Method | Path      | Purpose                                        |
| ------ | --------- | ---------------------------------------------- |
| `GET`  | `/models` | List available models for configured providers |

Native clients use `GET /mobile/v1/models`. The model list is fetched from provider APIs and
cached. Adding or changing a credential refreshes it on the next request. See
[AI Providers](/ai-providers).

## Speech

Dictation and read-aloud support. Mounted on both the unprefixed administrative surface and
`/mobile/v1`; native clients use the `/mobile/v1` paths. Hands-free voice mode is a separate,
WebSocket-based flow on the same `speech-v1` capability — see
[Voice frames](#voice-frames) under Resumable chat.

| Method  | Path                                        | Purpose                                                                           |
| ------- | ------------------------------------------- | --------------------------------------------------------------------------------- |
| `GET`   | `/speech/config`                            | Current speech configuration plus per-provider availability                       |
| `PATCH` | `/speech/config`                            | Merge a partial configuration and return the merged result                        |
| `GET`   | `/speech/models?kind=transcription\|speech` | Models the configured provider offers for one kind                                |
| `POST`  | `/speech/transcriptions`                    | Transcribe one recorded clip                                                      |
| `POST`  | `/speech/speech`                            | Synthesize speech, streamed as `audio/mpeg` (or `audio/wav` for a PCM-only model) |

`GET /health` advertises the `speech-v1` capability only while a speech provider can actually
transcribe and speak; an HQ without a configured provider omits it, and older clients must
tolerate capability strings they don't recognize.

`PATCH /speech/config` is a shallow per-section merge: an omitted key keeps its current value, and
an unknown key anywhere in the body is a `400` rather than a silent drop. `stt.language` is the one
field where the distinction between omitted and `null` matters — omitting it leaves the language
alone, while sending `null` clears it back to the provider's own auto-detection:

```bash theme={null}
curl -i -X PATCH "$MOBILE_URL/mobile/v1/speech/config" \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"stt": {"language": null}}'
```

`POST /speech/transcriptions` takes base64 audio inline rather than multipart, so every client
sends a plain JSON body:

```bash theme={null}
curl -i -X POST "$MOBILE_URL/mobile/v1/speech/transcriptions" \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"audio": "<base64>", "format": "m4a"}'
```

The decoded audio must be at most 8 MiB (`413 too_large`); a clip is expected to be at most 60
seconds. The HQ also fast-rejects on a `Content-Length` above 12 MB before reading the body at
all, so an oversized upload is refused without being buffered.

`POST /speech/speech` is the only operation in this namespace whose success body isn't JSON — it
streams MPEG audio, except for a PCM-only model (currently Google's Gemini TTS), which comes back
as a complete `audio/wav` file instead of a stream. `text` is capped at 4,000 characters
(`413 too_long`):

```bash theme={null}
curl -i -X POST "$MOBILE_URL/mobile/v1/speech/speech" \
  -H "Authorization: Bearer $MOBILE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "Hello from Dash."}' \
  --output reply.mp3
```

An error raised before the first audio chunk is still a JSON `MobileApiError`, so a client must
accept `audio/mpeg`, `audio/wav`, and `application/json` on this route.

`/speech/*` reuses the `MobileApiError` envelope but passes the provider-level error code through
untranslated: `unauthorized` (the provider rejected the API key), `unavailable` (no provider is
configured — retryable), `too_large` / `too_long` (payload over a limit), `provider` (the provider
returned an error), `network` (the HQ couldn't reach it), and `invalid` (a malformed request).

## Events

The unprefixed `GET /events` and replay routes remain available for Desktop. Native clients
use the corresponding `/mobile/v1` routes listed under **Conversations**. The HQ's durable
event log lets clients replay missed chat frames without sending the prompt to the model again.
See [Architecture](/architecture#event-log-and-chat-replay).

## Error responses

Mobile v1 failures use one structured shape:

```json theme={null}
{
  "code": "not_found",
  "error": "Conversation not found",
  "retryable": false
}
```

`details` is present only when the error has structured recovery data. Public codes are
`unauthorized`, `not_found`, `validation_failed`, `revision_conflict`, `conversation_busy`,
`rate_limited`, `gateway_offline`, and `capability_required`. Respect `retryable`; for rate limits,
also honor the server-provided retry delay.

Some unprefixed compatibility routes return the older `{ "error": "..." }` shape. Native clients
should use `/mobile/v1` and the structured errors above.

<Note>
  The loopback administrative API on port 9300 and desktop channel server on port 9200 retain their
  separate management and chat credentials. Native clients do not receive the administrative
  bearer: the pinned mobile listener uses one phone-scoped Mobile bearer for both `/mobile/v1` and
  `/ws/chat`.
</Note>
