| 1 | # Runtime API & Integration Contract |
| 2 | |
| 3 | DeepSeek TUI exposes a local runtime API through `deepseek serve --http` and |
| 4 | machine-readable health via `deepseek doctor --json`. It also exposes |
| 5 | `deepseek serve --acp` for editor clients that speak the Agent Client Protocol |
| 6 | over stdio. This document is the stable integration contract for native macOS |
| 7 | workbench applications (and other local supervisors) that embed the DeepSeek |
| 8 | engine without screen-scraping terminal output. |
| 9 | |
| 10 | ## Architecture |
| 11 | |
| 12 | ``` |
| 13 | macOS workbench (or any local supervisor) |
| 14 | │ |
| 15 | ├─ deepseek doctor --json → machine-readable health & capability |
| 16 | ├─ deepseek serve --http → HTTP/SSE runtime API |
| 17 | ├─ deepseek serve --acp → ACP stdio agent for editors such as Zed |
| 18 | ├─ deepseek serve --mcp → MCP stdio server |
| 19 | └─ deepseek [args] → interactive TUI session |
| 20 | ``` |
| 21 | |
| 22 | The engine runs as a local-only process. All APIs bind to `localhost` by |
| 23 | default. No hosted relay, no provider-token custody, no secret leakage. |
| 24 | |
| 25 | ## ACP stdio adapter: `deepseek serve --acp` |
| 26 | |
| 27 | `deepseek serve --acp` speaks JSON-RPC 2.0 over newline-delimited stdio for |
| 28 | ACP-compatible editor clients. The initial adapter implements the ACP baseline: |
| 29 | |
| 30 | - `initialize` |
| 31 | - `session/new` |
| 32 | - `session/prompt` |
| 33 | - `session/cancel` |
| 34 | |
| 35 | Prompt requests are routed through the configured DeepSeek client and current |
| 36 | default model. Responses are emitted as `session/update` agent message chunks |
| 37 | followed by a `session/prompt` response with `stopReason: "end_turn"`. |
| 38 | |
| 39 | The adapter is intentionally conservative: it does not yet expose shell tools, |
| 40 | file-write tools, checkpoint replay, or session loading through ACP. Use |
| 41 | `deepseek serve --http` for the full local runtime API and `deepseek serve --mcp` |
| 42 | when another client needs DeepSeek's tools as MCP tools. |
| 43 | |
| 44 | ## Capability endpoint: `deepseek doctor --json` |
| 45 | |
| 46 | Returns a JSON object describing the current installation's readiness state. |
| 47 | Suitable for health-check polling from a macOS workbench. |
| 48 | |
| 49 | ```bash |
| 50 | deepseek doctor --json |
| 51 | ``` |
| 52 | |
| 53 | ### Response schema (key fields) |
| 54 | |
| 55 | | Field | Type | Description | |
| 56 | |---|---|---| |
| 57 | | `version` | string | Installed version (e.g. `"0.8.9"`) | |
| 58 | | `config_path` | string | Resolved config file path | |
| 59 | | `config_present` | bool | Whether the config file exists | |
| 60 | | `workspace` | string | Default workspace directory | |
| 61 | | `api_key.source` | string | `env`, `config`, or `missing` | |
| 62 | | `base_url` | string | API base URL | |
| 63 | | `default_text_model` | string | Default model | |
| 64 | | `memory.enabled` | bool | Whether the memory feature is on | |
| 65 | | `memory.path` | string | Path to memory file | |
| 66 | | `memory.file_present` | bool | Whether memory file exists | |
| 67 | | `mcp.config_path` | string | MCP config file path | |
| 68 | | `mcp.present` | bool | Whether MCP config exists | |
| 69 | | `mcp.servers` | array | Per-server health: `{name, enabled, status, detail}` | |
| 70 | | `skills.selected` | string | Resolved skills directory | |
| 71 | | `skills.global.path` / `.present` / `.count` | — | DeepSeek global skills dir (`~/.deepseek/skills`) | |
| 72 | | `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir | |
| 73 | | `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) | |
| 74 | | `skills.local.path` / `.present` / `.count` | — | `skills/` dir | |
| 75 | | `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir | |
| 76 | | `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir | |
| 77 | | `tools.path` / `.present` / `.count` | — | Global tools directory | |
| 78 | | `plugins.path` / `.present` / `.count` | — | Global plugins directory | |
| 79 | | `sandbox.available` | bool | Whether sandbox is supported on this OS | |
| 80 | | `sandbox.kind` | string or null | Sandbox kind (e.g. `"macos_seatbelt"`) | |
| 81 | | `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir | |
| 82 | | `storage.stash.path` / `.present` / `.count` | — | Composer stash | |
| 83 | |
| 84 | ### Example |
| 85 | |
| 86 | ```json |
| 87 | { |
| 88 | "version": "0.8.9", |
| 89 | "config_path": "/Users/you/.deepseek/config.toml", |
| 90 | "config_present": true, |
| 91 | "workspace": "/Users/you/projects/deepseek-tui", |
| 92 | "api_key": { |
| 93 | "source": "env" |
| 94 | }, |
| 95 | "base_url": "https://api.deepseek.com", |
| 96 | "default_text_model": "deepseek-v4-pro", |
| 97 | "memory": { |
| 98 | "enabled": false, |
| 99 | "path": "/Users/you/.deepseek/memory.md", |
| 100 | "file_present": true |
| 101 | }, |
| 102 | "mcp": { |
| 103 | "config_path": "/Users/you/.deepseek/mcp.json", |
| 104 | "present": true, |
| 105 | "servers": [ |
| 106 | {"name": "filesystem", "enabled": true, "status": "ok", "detail": "ready"} |
| 107 | ] |
| 108 | }, |
| 109 | "sandbox": { |
| 110 | "available": true, |
| 111 | "kind": "macos_seatbelt" |
| 112 | } |
| 113 | } |
| 114 | ``` |
| 115 | |
| 116 | ## HTTP/SSE runtime API: `deepseek serve --http` |
| 117 | |
| 118 | ```bash |
| 119 | deepseek serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] |
| 120 | ``` |
| 121 | |
| 122 | Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). |
| 123 | |
| 124 | The server binds to `localhost` by default. Configuration is via CLI flags — |
| 125 | there is no `[app_server]` config section. |
| 126 | |
| 127 | By default, existing local behavior is unchanged and `/v1/*` routes are not |
| 128 | authenticated. To require a bearer token for `/v1/*` routes, pass |
| 129 | `--auth-token TOKEN` or set `DEEPSEEK_RUNTIME_TOKEN=TOKEN` before starting the |
| 130 | server. `/health` remains public for local process supervision and readiness |
| 131 | checks. |
| 132 | |
| 133 | Authenticated clients can provide the token as `Authorization: Bearer TOKEN`, |
| 134 | `X-DeepSeek-Runtime-Token: TOKEN`, or `?token=TOKEN` for EventSource-style |
| 135 | clients that cannot set custom headers. |
| 136 | |
| 137 | ### Endpoints |
| 138 | |
| 139 | **Health** |
| 140 | - `GET /health` |
| 141 | |
| 142 | **Sessions** (legacy session manager) |
| 143 | - `GET /v1/sessions?limit=50&search=<substring>` |
| 144 | - `GET /v1/sessions/{id}` |
| 145 | - `DELETE /v1/sessions/{id}` |
| 146 | - `POST /v1/sessions/{id}/resume-thread` |
| 147 | |
| 148 | **Threads** (durable runtime data model) |
| 149 | - `GET /v1/threads?limit=50&include_archived=false&archived_only=false` |
| 150 | - `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false&archived_only=false` |
| 151 | - `POST /v1/threads` |
| 152 | - `GET /v1/threads/{id}` |
| 153 | - `PATCH /v1/threads/{id}` (see body shape below) |
| 154 | - `POST /v1/threads/{id}/resume` |
| 155 | - `POST /v1/threads/{id}/fork` |
| 156 | |
| 157 | `archived_only=true` returns archived threads only (mutually overrides |
| 158 | `include_archived`). Default behavior is unchanged: `include_archived=false` |
| 159 | and `archived_only=false` returns active threads. Added in v0.8.10 (#563). |
| 160 | |
| 161 | `PATCH /v1/threads/{id}` body — every field is optional, missing means |
| 162 | "no change". At least one field must be present. `title` and `system_prompt` |
| 163 | accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): |
| 164 | |
| 165 | ```json |
| 166 | { |
| 167 | "archived": true, |
| 168 | "allow_shell": false, |
| 169 | "trust_mode": false, |
| 170 | "auto_approve": false, |
| 171 | "model": "deepseek-v4-pro", |
| 172 | "mode": "agent", |
| 173 | "title": "User-set thread title", |
| 174 | "system_prompt": "You are a useful assistant." |
| 175 | } |
| 176 | ``` |
| 177 | |
| 178 | **Turns** (within a thread) |
| 179 | - `POST /v1/threads/{id}/turns` |
| 180 | - `POST /v1/threads/{id}/turns/{turn_id}/steer` |
| 181 | - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |
| 182 | - `POST /v1/threads/{id}/compact` (manual compaction) |
| 183 | |
| 184 | **Events** (SSE replay + live stream) |
| 185 | - `GET /v1/threads/{id}/events?since_seq=<u64>` |
| 186 | |
| 187 | **Compatibility stream** (one-shot, backwards-compatible) |
| 188 | - `POST /v1/stream` |
| 189 | |
| 190 | **Tasks** (durable background work) |
| 191 | - `GET /v1/tasks` |
| 192 | - `POST /v1/tasks` |
| 193 | - `GET /v1/tasks/{id}` |
| 194 | - `POST /v1/tasks/{id}/cancel` |
| 195 | |
| 196 | **Automations** (scheduled recurring work) |
| 197 | - `GET /v1/automations` |
| 198 | - `POST /v1/automations` |
| 199 | - `GET /v1/automations/{id}` |
| 200 | - `PATCH /v1/automations/{id}` |
| 201 | - `DELETE /v1/automations/{id}` |
| 202 | - `POST /v1/automations/{id}/run` |
| 203 | - `POST /v1/automations/{id}/pause` |
| 204 | - `POST /v1/automations/{id}/resume` |
| 205 | - `GET /v1/automations/{id}/runs?limit=20` |
| 206 | |
| 207 | **Introspection** |
| 208 | - `GET /v1/workspace/status` |
| 209 | - `GET /v1/skills` |
| 210 | - `GET /v1/apps/mcp/servers` |
| 211 | - `GET /v1/apps/mcp/tools?server=<optional>` |
| 212 | |
| 213 | **Usage** (token/cost aggregation across threads) |
| 214 | - `GET /v1/usage?since=<rfc3339>&until=<rfc3339>&group_by=<day|model|provider|thread>` |
| 215 | |
| 216 | `since` / `until` are inclusive RFC 3339 timestamps and may be omitted (no |
| 217 | bound). `group_by` defaults to `day`. Buckets are sorted by ascending key. |
| 218 | Empty time ranges produce empty `buckets` (never a 404). Cost is computed via |
| 219 | the model→pricing map; turns whose model has no pricing entry contribute |
| 220 | tokens but `0.0` cost. Added in v0.8.10 (#564). |
| 221 | |
| 222 | ```json |
| 223 | { |
| 224 | "since": "2026-04-01T00:00:00Z", |
| 225 | "until": "2026-04-30T23:59:59Z", |
| 226 | "group_by": "day", |
| 227 | "totals": { |
| 228 | "input_tokens": 12345, |
| 229 | "output_tokens": 6789, |
| 230 | "cached_tokens": 0, |
| 231 | "reasoning_tokens": 0, |
| 232 | "cost_usd": 0.012, |
| 233 | "turns": 42 |
| 234 | }, |
| 235 | "buckets": [ |
| 236 | { |
| 237 | "key": "2026-04-30", |
| 238 | "input_tokens": 1234, |
| 239 | "output_tokens": 678, |
| 240 | "cached_tokens": 0, |
| 241 | "reasoning_tokens": 0, |
| 242 | "cost_usd": 0.001, |
| 243 | "turns": 3 |
| 244 | } |
| 245 | ] |
| 246 | } |
| 247 | ``` |
| 248 | |
| 249 | ## Runtime data model |
| 250 | |
| 251 | The runtime uses a durable Thread/Turn/Item lifecycle. |
| 252 | |
| 253 | - **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`, `workspace`, |
| 254 | `mode`, `task_id`, `coherence_state`, `system_prompt`, `latest_turn_id`, |
| 255 | `latest_response_bookmark`, `archived` |
| 256 | - **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed| |
| 257 | failed|interrupted|canceled`), timestamps, duration, usage, error summary |
| 258 | - **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message| |
| 259 | tool_call|file_change|command_execution|context_compaction|status|error`), |
| 260 | lifecycle `status`, `metadata` |
| 261 | |
| 262 | Events are append-only with a global monotonic `seq` for replay/resume. |
| 263 | |
| 264 | ### Restart semantics |
| 265 | |
| 266 | - If the process restarts while a turn or item is `queued` or `in_progress`, |
| 267 | the recovered record is marked `interrupted` with an `"Interrupted by |
| 268 | process restart"` error. |
| 269 | - Task execution performs its own recovery on top of the same persisted |
| 270 | thread/turn store. |
| 271 | |
| 272 | ### Approval model |
| 273 | |
| 274 | - The `auto_approve` flag applies to the runtime approval bridge and engine |
| 275 | tool context. When enabled for a thread/turn/task, approval-required tools |
| 276 | are auto-approved in the non-interactive runtime path, shell safety checks |
| 277 | run in auto-approved mode, and spawned sub-agents inherit that setting. |
| 278 | - When omitted, `auto_approve` defaults to `false`. |
| 279 | |
| 280 | ### SSE event stream |
| 281 | |
| 282 | The SSE event payload shape: |
| 283 | |
| 284 | ```json |
| 285 | { |
| 286 | "seq": 42, |
| 287 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 288 | "thread_id": "thr_1234abcd", |
| 289 | "turn_id": "turn_5678efgh", |
| 290 | "item_id": "item_90ab12cd", |
| 291 | "event": "item.delta", |
| 292 | "payload": { |
| 293 | "delta": "partial output", |
| 294 | "kind": "agent_message" |
| 295 | } |
| 296 | } |
| 297 | ``` |
| 298 | |
| 299 | Common event names: `thread.started`, `thread.forked`, `turn.started`, |
| 300 | `turn.lifecycle`, `turn.steered`, `turn.interrupt_requested`, |
| 301 | `turn.completed`, `item.started`, `item.delta`, `item.completed`, |
| 302 | `item.failed`, `item.interrupted`, `approval.required`, `sandbox.denied`, |
| 303 | `coherence.state`. |
| 304 | |
| 305 | ## Security boundary |
| 306 | |
| 307 | - **Localhost only**. The server binds to `127.0.0.1` by default. Set |
| 308 | `--host 0.0.0.0` only when you have a reverse-proxy / VPN that |
| 309 | authenticates. The runtime does not provide user isolation or TLS. |
| 310 | - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN` |
| 311 | requires a matching bearer token for `/v1/*` routes. This is a local |
| 312 | convenience guard, not a replacement for TLS, VPN, or a trusted reverse |
| 313 | proxy on public networks. |
| 314 | - **No provider-token custody**. The server never returns the API key. The |
| 315 | `api_key.source` capability field reports `env`, `config`, or `missing` — |
| 316 | never the key itself. |
| 317 | - **No hosted relay**. The app-server is a local process under the user's |
| 318 | control. There is no cloud component. |
| 319 | - **Capability responses** never leak secrets, file contents, or session |
| 320 | message bodies. They report *metadata*: presence, counts, status flags. |
| 321 | |
| 322 | ### CORS allow-list |
| 323 | |
| 324 | The runtime API ships with a built-in dev-origin allow-list: |
| 325 | `http://localhost:3000`, `http://127.0.0.1:3000`, `http://localhost:1420`, |
| 326 | `http://127.0.0.1:1420`, `tauri://localhost`. To add additional origins (e.g. |
| 327 | when developing a UI on Vite's default `:5173`), use any of: |
| 328 | |
| 329 | - CLI flag (repeatable): `deepseek serve --http --cors-origin http://localhost:5173` |
| 330 | - Env var (comma-separated): `DEEPSEEK_CORS_ORIGINS="http://localhost:5173,http://localhost:8080"` |
| 331 | - Config (`~/.deepseek/config.toml`): |
| 332 | ```toml |
| 333 | [runtime_api] |
| 334 | cors_origins = ["http://localhost:5173"] |
| 335 | ``` |
| 336 | |
| 337 | User-supplied origins **stack on top of** the built-in defaults; they do not |
| 338 | replace them. Wildcard origins are not supported — the explicit allow-list |
| 339 | model is preserved. Added in v0.8.10 (#561). |
| 340 | |
| 341 | ## Session lifecycle (native UI supervision) |
| 342 | |
| 343 | | Operation | Endpoint | |
| 344 | |---|---| |
| 345 | | List sessions | `GET /v1/sessions` | |
| 346 | | Get session | `GET /v1/sessions/{id}` | |
| 347 | | Delete session | `DELETE /v1/sessions/{id}` | |
| 348 | | Resume into thread | `POST /v1/sessions/{id}/resume-thread` | |
| 349 | | Create thread | `POST /v1/threads` | |
| 350 | | List threads | `GET /v1/threads` | |
| 351 | | Attach to events | `GET /v1/threads/{id}/events?since_seq=0` | |
| 352 | | Send message | `POST /v1/threads/{id}/turns` | |
| 353 | | Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` | |
| 354 | | Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` | |
| 355 | | Compact | `POST /v1/threads/{id}/compact` | |
| 356 | |
| 357 | ## Compatibility tests |
| 358 | |
| 359 | Contract snapshots live in `crates/protocol/tests/`. Run: |
| 360 | |
| 361 | ```bash |
| 362 | cargo test -p deepseek-protocol --test parity_protocol --locked |
| 363 | ``` |
| 364 | |
| 365 | This validates that the app-server's event schema hasn't drifted from the |
| 366 | documented contract. CI runs this on every push to `main` and on release tags. |
| 367 |