| 1 | # Runtime API & Integration Contract |
| 2 | |
| 3 | `codewhale app-server` is the canonical local runtime API and control plane. |
| 4 | Local SDKs, mobile/remote-control clients, and editor integrations talk to it |
| 5 | instead of screen-scraping terminal output. It serves the full HTTP/SSE runtime |
| 6 | API (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly |
| 7 | mobile page. `codewhale doctor --json` provides machine-readable health, and |
| 8 | `codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors |
| 9 | such as Zed. |
| 10 | |
| 11 | `codewhale serve --http` / `serve --mobile` remain as **compatibility aliases** |
| 12 | for `codewhale app-server --http` / `--mobile`; both launch the identical |
| 13 | server. New integrations should target `app-server`. |
| 14 | |
| 15 | `codewhale exec` is the separate one-shot headless worker path (stream-json, |
| 16 | fleet worker subprocess, CI primitive). It is not part of this API, but it |
| 17 | shares the same runtime, provider/model resolution, permission profiles, and |
| 18 | event vocabulary. |
| 19 | |
| 20 | This document is the stable integration contract for native workbench |
| 21 | applications (and other local supervisors) that embed the DeepSeek engine. |
| 22 | |
| 23 | ## Architecture |
| 24 | |
| 25 | ``` |
| 26 | local supervisor / SDK / automation harness |
| 27 | │ |
| 28 | ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical] |
| 29 | ├─ codewhale app-server --mobile → runtime API + mobile control page |
| 30 | ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio |
| 31 | ├─ codewhale doctor --json → machine-readable health & capability |
| 32 | ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed |
| 33 | ├─ codewhale serve --mcp → MCP stdio server |
| 34 | ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile` |
| 35 | └─ codewhale exec [args] → one-shot headless worker (stream-json) |
| 36 | ``` |
| 37 | |
| 38 | The engine runs as a local-only process. All APIs bind to `localhost` by |
| 39 | default. No hosted relay, no provider-token custody, no secret leakage. |
| 40 | |
| 41 | For a proposed read-only audit export over completed turns, see |
| 42 | [`docs/RECEIPTS.md`](RECEIPTS.md). That document is a protocol note; the receipt |
| 43 | CLI/API surfaces are not implemented yet. |
| 44 | |
| 45 | ## Runtime API entrypoints |
| 46 | |
| 47 | | Entry | Transport | Use | |
| 48 | |---|---|---| |
| 49 | | `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser | |
| 50 | | `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) | |
| 51 | | `codewhale app-server --mobile` | HTTP/SSE on `0.0.0.0:7878` + `/mobile` | Runtime API + phone control page | |
| 52 | | `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) | |
| 53 | | `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`, `/tool`, `/jobs`) | |
| 54 | | `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases | |
| 55 | |
| 56 | `app-server --http` and `--mobile` launch the same mature runtime API server |
| 57 | historically reached through `serve --http` — no routes or behavior changed, so |
| 58 | every endpoint documented below is identical across both entrypoints. The |
| 59 | runtime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`, |
| 60 | then `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback |
| 61 | bind. The `serve` compatibility aliases keep their `--insecure` flag. |
| 62 | The legacy in-process `codewhale app-server` also requires an explicit |
| 63 | `--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback |
| 64 | host; its generated one-time `cwapp_*` token is loopback-only. |
| 65 | |
| 66 | ### Runtime and account identity |
| 67 | |
| 68 | `GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character |
| 69 | `codewhale_commit` embedded by the shared CLI/TUI build. A source archive that |
| 70 | cannot provide an exact commit reports `unknown`, allowing compatibility |
| 71 | clients to fail closed rather than accepting an ambiguous binary pair. |
| 72 | |
| 73 | The same response advertises `capabilities.account_session: true` and a |
| 74 | token-free account receipt: |
| 75 | |
| 76 | ```json |
| 77 | { |
| 78 | "account": { |
| 79 | "schema_version": 1, |
| 80 | "state": "authenticated", |
| 81 | "api_base": "https://api.codewhale.net", |
| 82 | "account_id": "acct_...", |
| 83 | "session_id": "session_...", |
| 84 | "scopes": [], |
| 85 | "expires_at": "2026-08-01T20:00:00Z" |
| 86 | } |
| 87 | } |
| 88 | ``` |
| 89 | |
| 90 | The Runtime reads this receipt from the exact profile- and API-origin-scoped |
| 91 | secure record written by `codewhale account login`; it does not run a second |
| 92 | login flow. States are `signed_out`, `authenticated`, `offline_cached`, |
| 93 | `expired`, or `revoked`. Scopes are copied only from explicit stored session |
| 94 | grants and are never inferred from account identity. Access/refresh tokens, |
| 95 | email, provider profile, and provider credentials are never returned. |
| 96 | `account_id` and `session_id` are included only for a request authorized with |
| 97 | the Runtime token (or an explicitly insecure loopback server); the public |
| 98 | bootstrap response remains usable but reports `signed_out`. Signed-out local |
| 99 | Work remains supported and never allocates cloud compute implicitly. |
| 100 | |
| 101 | The `--stdio` control transport is newline-delimited JSON-RPC 2.0. Probe it |
| 102 | without spending model tokens: |
| 103 | |
| 104 | ```bash |
| 105 | printf '%s\n' \ |
| 106 | '{"jsonrpc":"2.0","id":1,"method":"healthz"}' \ |
| 107 | '{"jsonrpc":"2.0","id":2,"method":"capabilities"}' \ |
| 108 | '{"jsonrpc":"2.0","id":3,"method":"shutdown"}' \ |
| 109 | | codewhale app-server --stdio |
| 110 | ``` |
| 111 | |
| 112 | `capabilities` returns the advertised method families (`thread/*`, `app/*`, |
| 113 | `prompt/*`) and the full method list; `thread/capabilities`, |
| 114 | `app/capabilities`, and `prompt/capabilities` scope it per family. The method |
| 115 | set is pinned by a drift test in `crates/app-server/src/lib.rs`, so SDK and |
| 116 | local integration clients can rely on it not changing silently. |
| 117 | |
| 118 | ### Interrupting a turn |
| 119 | |
| 120 | `thread/message` streams until the turn reaches a terminal state, which can |
| 121 | take minutes. The read loop keeps polling stdin while a turn streams, so a |
| 122 | client can send: |
| 123 | |
| 124 | ```json |
| 125 | {"jsonrpc":"2.0","id":9,"method":"thread/interrupt","params":{"thread_id":"thr_..."}} |
| 126 | ``` |
| 127 | |
| 128 | and the runtime is asked to interrupt that turn |
| 129 | (`POST /v1/threads/{id}/turns/{turn_id}/interrupt`). The reply carries |
| 130 | `interrupted: false` when no turn is streaming for that thread — this is not |
| 131 | an error, just nothing to stop. The interrupted `thread/message` then fails |
| 132 | with a `turn interrupted` error, and its reply is written before the |
| 133 | interrupt's own reply, since the turn owns the writer until it unwinds. |
| 134 | |
| 135 | `shutdown` sent during a live turn also interrupts first: it needs the same |
| 136 | bridge that the turn holds, so without that it would wait for the very turn |
| 137 | it was meant to stop. Other requests that arrive mid-turn are queued and run |
| 138 | in order once the turn finishes. |
| 139 | |
| 140 | ## SDK contract |
| 141 | |
| 142 | The app-server exists so an external SDK can answer — without scraping TUI |
| 143 | output — *what route ran, which provider/model/reasoning/permission profile was |
| 144 | effective, what events happened, how many tokens were used, and how the run |
| 145 | finished.* The durable Thread/Turn/Item data model already carries most of |
| 146 | this; the table maps each integration need to where a local client reads it. |
| 147 | |
| 148 | | Integration need | Where it comes from | Status | |
| 149 | |---|---|---| |
| 150 | | Route / effective model / billing surface | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available | |
| 151 | | Permission / sandbox / approval profile | thread `auto_approve`, sandbox + approval policy | available | |
| 152 | | Run / thread / turn IDs | `thread_id`, `turn_id`, SSE event envelope | available | |
| 153 | | Event stream | `GET /v1/threads/{id}/events` (replay + live SSE) | available | |
| 154 | | Turn status / terminal classification | `TurnRecord.status` + error summary | available | |
| 155 | | Token usage | `TurnRecord.usage`; aggregate via `GET /v1/usage` | available | |
| 156 | | Single-read run receipt (route + usage + cost) | `GET /v1/threads/{id}/turns/{turn_id}/receipt` | proposed ([RECEIPTS.md](RECEIPTS.md)) | |
| 157 | |
| 158 | For one-shot/headless automation, prefer `codewhale exec` with explicit |
| 159 | `--provider <id> --model <id>` so a failure identifies the exact provider/model |
| 160 | pair. Use `app-server` when a local integration needs to start, resume, steer, |
| 161 | or interrupt turns, list models/capabilities, follow the event stream, or read |
| 162 | usage. Both paths share the same runtime, so route-effective model resolution |
| 163 | and the event vocabulary match. |
| 164 | |
| 165 | ### Release smoke |
| 166 | |
| 167 | `scripts/release/app-server-smoke.sh` is the committed pre-release check: |
| 168 | |
| 169 | ```bash |
| 170 | scripts/release/app-server-smoke.sh # stdio health/capabilities probe (no tokens) |
| 171 | scripts/release/app-server-smoke.sh --matrix # + print the configured provider/model matrix |
| 172 | scripts/release/app-server-smoke.sh --matrix --real # + exec a cheap sentinel per provider |
| 173 | ``` |
| 174 | |
| 175 | The stdio probe runs against a throwaway config, so it never reads real keys. |
| 176 | The matrix discovers configured providers from `codewhale auth list`, skips |
| 177 | unconfigured providers, and maps a provider to a cheap sentinel model only when |
| 178 | it has a built-in cheap default. That built-in set is deliberately conservative |
| 179 | (currently `deepseek`, `zai`, `moonshot`, and `openai`); every other provider — |
| 180 | including `arcee`, `openrouter`, `xiaomi-mimo`, and `openai-codex` — is left |
| 181 | unmapped on purpose and must be given a model per run via `SMOKE_MODEL_<SLUG>` |
| 182 | rather than a guessed default (#3205). Any configured-but-unmapped provider |
| 183 | fails loudly in `--real` mode. `auth list` reports presence flags only and exec |
| 184 | output is passed through a redactor, so secrets are never printed. The parser is |
| 185 | covered by `scripts/release/app-server-smoke.test.sh` against a fake `codewhale` |
| 186 | binary. |
| 187 | |
| 188 | ## ACP stdio adapter: `codewhale serve --acp` |
| 189 | |
| 190 | `codewhale serve --acp` speaks JSON-RPC 2.0 over newline-delimited stdio for |
| 191 | ACP-compatible editor clients. The initial adapter implements the ACP baseline: |
| 192 | |
| 193 | - `initialize` |
| 194 | - `session/new` |
| 195 | - `session/prompt` |
| 196 | - `session/cancel` |
| 197 | |
| 198 | Prompt requests are routed through the configured DeepSeek client and current |
| 199 | default model. Responses are emitted as `session/update` agent message chunks |
| 200 | followed by a `session/prompt` response with `stopReason: "end_turn"`. |
| 201 | |
| 202 | The adapter is intentionally conservative: it does not yet expose shell tools, |
| 203 | file-write tools, checkpoint replay, or session loading through ACP. Use |
| 204 | `codewhale serve --http` for the full local runtime API and `codewhale serve --mcp` |
| 205 | when another client needs DeepSeek's tools as MCP tools. |
| 206 | |
| 207 | ## Capability endpoint: `codewhale doctor --json` |
| 208 | |
| 209 | Returns a JSON object describing the current installation's readiness state. |
| 210 | Suitable for health-check polling from a macOS workbench. This command is |
| 211 | strictly structural and offline: it does not load workspace credential |
| 212 | `.env` files, inspect credential environment values, open secret/OAuth files, |
| 213 | probe an OS keyring, contact providers, or start MCP processes. |
| 214 | |
| 215 | ```bash |
| 216 | codewhale doctor --json |
| 217 | ``` |
| 218 | |
| 219 | ### Response schema (key fields) |
| 220 | |
| 221 | | Field | Type | Description | |
| 222 | |---|---|---| |
| 223 | | `version` | string | Installed version (e.g. `"0.8.9"`) | |
| 224 | | `config_path` | string | Resolved config file path | |
| 225 | | `config_present` | bool | Whether the config file exists | |
| 226 | | `paths` | object | Canonical config, settings, state, sessions, logs, automations, and secrets paths | |
| 227 | | `secret_backend` | object | Metadata-only file-store shape, or literal `unknown` / `not_probed` for system and unsupported backends | |
| 228 | | `workspace` | string | Default workspace directory | |
| 229 | | `legacy_state.primary_root` | string | Primary Codewhale state root inspected for known state paths | |
| 230 | | `legacy_state.legacy_root` | string | Legacy `.deepseek` state root inspected for known state paths | |
| 231 | | `legacy_state.needs_attention` | bool | Whether known `~/.deepseek` state paths need review or the read-only session recovery diagnostic found missing destination filenames / could not complete | |
| 232 | | `legacy_state.legacy_only_count` | number | Count of known state paths present only under the legacy root | |
| 233 | | `legacy_state.dual_present_count` | number | Count of known state paths present under both primary and legacy roots | |
| 234 | | `legacy_state.entries` | array | Per-path migration status: `{name, primary_present, legacy_present, status}` | |
| 235 | | `legacy_state.session_recovery.status` | string | `isolated`, `no_legacy_sessions`, `migration_pending`, `migration_incomplete`, `migration_complete`, or `scan_failed` | |
| 236 | | `legacy_state.session_recovery.read_only` | bool | Always true; doctor never invokes session migration or modifies either session directory | |
| 237 | | `legacy_state.session_recovery.chat_contents_read` | bool | Always false; comparison is based only on top-level `.json` filenames and filesystem metadata | |
| 238 | | `legacy_state.session_recovery.checkpoint_internals_scanned` | bool | Always false; `sessions/checkpoints/` and all other directories are skipped | |
| 239 | | `legacy_state.session_recovery.recoverable_files` | array | Bounded sample of up to 100 missing destination filenames with source and destination paths; no chat payloads | |
| 240 | | `legacy_state.session_recovery.recoverable_file_count` | number | Total missing destination filename count, including entries beyond the bounded sample | |
| 241 | | `legacy_state.session_recovery.recoverable_files_truncated` | bool | Whether more than 100 recoverable filenames were found | |
| 242 | | `legacy_state.session_recovery.recovery_command` | string or null | `codewhale sessions` when additive automatic recovery is available; null for isolated, complete, empty, or failed scans | |
| 243 | | `api_key.source` | string | Structural source state: `config_declared`, `env_declared`, `external_auth_declared`, `secret_store_unprobed`, `secret_store_unavailable`, `oauth_unprobed`, `external_consent`, `none`, `local_runtime`, or `unknown`; declarations are not availability proof | |
| 244 | | `api_key.availability` | string | Literal `present`, `not_required`, `not_probed`, `unavailable`, or `unknown`; only `present` and `not_required` certify structural Setup/Fleet credential readiness | |
| 245 | | `base_url` | string | Provider URL authority only (`scheme://host[:explicit-port]`); userinfo, path, query, and fragment are omitted | |
| 246 | | `default_text_model` | string | Default model | |
| 247 | | `memory.enabled` | bool | Whether the memory feature is on | |
| 248 | | `memory.path` | string | Path to memory file | |
| 249 | | `memory.file_present` | bool | Whether memory file exists | |
| 250 | | `mcp.config_path` | string | MCP config file path | |
| 251 | | `mcp.present` | bool | Whether MCP config exists | |
| 252 | | `mcp.probe_scope` | string | `configuration`; doctor does not start MCP servers | |
| 253 | | `mcp.live_health_checked` | bool | Always false for doctor JSON | |
| 254 | | `mcp.servers` | array | Per-server structural result and counts plus separate `checks`; URL userinfo/path/query/fragment and command argv, environment, header, and token values are never emitted, and all live stages are `not_checked` | |
| 255 | | `skills.selected` | string | Resolved skills directory | |
| 256 | | `skills.global.path` / `.present` / `.count` | — | Codewhale global skills dir (`~/.codewhale/skills`, with legacy `~/.deepseek/skills` support) | |
| 257 | | `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir | |
| 258 | | `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) | |
| 259 | | `skills.local.path` / `.present` / `.count` | — | `skills/` dir | |
| 260 | | `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir | |
| 261 | | `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir | |
| 262 | | `tools.path` / `.present` / `.count` | — | Global tools directory | |
| 263 | | `plugins.path` / `.present` / `.count` | — | Global plugins directory | |
| 264 | | `sandbox.available` | bool | Whether sandbox is supported on this OS | |
| 265 | | `sandbox.kind` | string or null | Sandbox kind (e.g. `"macos_seatbelt"`) | |
| 266 | | `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir | |
| 267 | | `storage.stash.path` / `.present` / `.count` | — | Composer stash | |
| 268 | |
| 269 | ### Example |
| 270 | |
| 271 | ```json |
| 272 | { |
| 273 | "version": "0.8.9", |
| 274 | "config_path": "/Users/you/.codewhale/config.toml", |
| 275 | "config_present": true, |
| 276 | "workspace": "/Users/you/projects/codewhale-tui", |
| 277 | "api_key": { |
| 278 | "source": "secret_store_unprobed", |
| 279 | "availability": "not_probed" |
| 280 | }, |
| 281 | "base_url": "https://api.deepseek.com", |
| 282 | "default_text_model": "deepseek-v4-pro", |
| 283 | "memory": { |
| 284 | "enabled": false, |
| 285 | "path": "/Users/you/.codewhale/memory.md", |
| 286 | "file_present": true |
| 287 | }, |
| 288 | "mcp": { |
| 289 | "config_path": "/Users/you/.codewhale/mcp.json", |
| 290 | "present": true, |
| 291 | "servers": [ |
| 292 | {"name": "filesystem", "enabled": true, "transport": "stdio", "args_count": 2, "env_count": 0, "status": "ok"} |
| 293 | ] |
| 294 | }, |
| 295 | "sandbox": { |
| 296 | "available": true, |
| 297 | "kind": "macos_seatbelt" |
| 298 | } |
| 299 | } |
| 300 | ``` |
| 301 | |
| 302 | ## HTTP/SSE runtime API: `codewhale app-server --http` |
| 303 | |
| 304 | ```bash |
| 305 | codewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth] |
| 306 | codewhale app-server --mobile [--host 0.0.0.0] [--port 7878] [--auth-token TOKEN] |
| 307 | codewhale app-server --mobile --host 127.0.0.1 [--port 7878] [--insecure-no-auth] |
| 308 | codewhale web [--port 7878] |
| 309 | |
| 310 | # Compatibility aliases — identical server, serve flag names: |
| 311 | codewhale serve --http [...] [--insecure] |
| 312 | codewhale serve --mobile [...] [--insecure] |
| 313 | ``` |
| 314 | |
| 315 | Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). |
| 316 | |
| 317 | The server binds to `localhost` by default. Configuration is via CLI flags — |
| 318 | there is no `[app_server]` config section. |
| 319 | |
| 320 | `/v1/*` routes require a bearer token unless `codewhale app-server` is started |
| 321 | with `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Do not combine |
| 322 | no-auth mode with the `--mobile` default host `0.0.0.0`; use a token for LAN |
| 323 | mobile access, or add `--host 127.0.0.1` for local-only no-auth testing. The |
| 324 | `codewhale serve` compatibility aliases use `--insecure` for the same loopback |
| 325 | escape hatch. |
| 326 | Pass `--auth-token TOKEN` or set `CODEWHALE_RUNTIME_TOKEN=TOKEN` before starting |
| 327 | the server; `DEEPSEEK_RUNTIME_TOKEN` remains a compatibility alias. If neither |
| 328 | is set, the process generates a Runtime token for that process and does **not** |
| 329 | print it. `/health`, `/v1/runtime/info`, and an enabled static client shell |
| 330 | remain public; Runtime mutations and thread data stay behind `/v1/*` |
| 331 | authentication. `/mobile` returns 404 when mobile mode is disabled and serves |
| 332 | the unchanged static shell when it is enabled. |
| 333 | |
| 334 | Authenticated clients can provide the token as `Authorization: Bearer TOKEN`, |
| 335 | `X-Codewhale-Runtime-Token: TOKEN`, the legacy |
| 336 | `X-DeepSeek-Runtime-Token: TOKEN`, or the `codewhale_runtime_token` cookie. |
| 337 | Query-string authentication is not supported. |
| 338 | |
| 339 | ### Local browser client |
| 340 | |
| 341 | `codewhale web` starts the canonical Runtime API on `127.0.0.1`, serves |
| 342 | dependency-free assets embedded in the binary, and opens the default browser. |
| 343 | It cannot bind to a non-loopback host and cannot run with Runtime auth disabled. |
| 344 | |
| 345 | The browser-launch URL contains a random, short-lived, one-time bootstrap |
| 346 | capability, never the Runtime token. A loopback request exchanges that |
| 347 | capability for a |
| 348 | `codewhale_web_session=…; HttpOnly; SameSite=Strict; Path=/` cookie backed by a |
| 349 | single process-local server session that expires 12 hours after the server |
| 350 | process starts, consumes the capability immediately, and redirects to `/`. |
| 351 | Reused, expired, malformed, or |
| 352 | non-loopback bootstrap attempts fail closed. The Runtime bearer token is not |
| 353 | placed in rendered HTML, browser storage, logs, URL queries/fragments, or |
| 354 | browser-launch arguments. The one-time bootstrap capability does transit the |
| 355 | OS browser launcher's argument list for a sub-second window; a same-user |
| 356 | process scraping the process table in that window could race the browser to |
| 357 | the exchange, which is why the capability is single-use, loopback-only, and |
| 358 | expiring — and why a same-user attacker has strictly easier local avenues |
| 359 | than this race. |
| 360 | Existing bearer/header/cookie authorization for `/v1/*` is unchanged outside |
| 361 | web mode. In web mode, cookie-authenticated unsafe requests must also carry the |
| 362 | exact local web origin, and Fetch Metadata identifying a cross-origin cookie |
| 363 | request is rejected. Explicit bearer and Runtime-token header clients keep |
| 364 | their existing behavior. |
| 365 | |
| 366 | The v0.9.1 client provides a responsive thread/search rail, Runtime-owned |
| 367 | session facts, transcript and tool receipts, and a bottom composer. It can |
| 368 | create, select, rename, and archive threads; start or steer turns; interrupt |
| 369 | work; resolve approvals; and answer Runtime user-input requests. Selection |
| 370 | loads `GET /v1/threads/{id}` first, then opens the replayable event stream with |
| 371 | `since_seq=latest_seq`; reconnection advances from the newest accepted sequence |
| 372 | and drops duplicates or events from a stale selection. The thread detail |
| 373 | snapshot includes `pending_approvals`, `pending_user_inputs`, and |
| 374 | `pending_dynamic_tool_calls`; clients must hydrate those fields before |
| 375 | subscribing so a reload cannot strand work whose request event is at or before |
| 376 | `latest_seq`. Resolution is also published as `approval.decided`, |
| 377 | `user_input.answered`, `user_input.canceled`, `tool_call.resolved`, |
| 378 | `tool_call.canceled`, or `tool_call.timeout` for already-connected clients. |
| 379 | |
| 380 | Model, mode, permission posture, workspace, and branch are display-only in this |
| 381 | client. Files/Changes, PTY/terminal, preview, artifacts, provider login/model |
| 382 | selection, Fleet creation, and undo/retry/restore controls are intentionally |
| 383 | absent until the Runtime publishes explicit contracts for them. |
| 384 | |
| 385 | ### Mobile control page |
| 386 | |
| 387 | `codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a |
| 388 | phone-friendly control page at `/mobile`. When the bind host is left at the |
| 389 | default, mobile mode binds to `0.0.0.0`, prints a warning, and prints local/LAN |
| 390 | URLs. Pass `--host 127.0.0.1` to keep the mobile page loopback-only. The static |
| 391 | HTML page contains no secrets and is not itself token-gated. Its calls to |
| 392 | `/v1/*` are authenticated: for LAN use, start with an explicit Runtime token |
| 393 | and enter it in the page. Generated Runtime tokens are deliberately unprinted, |
| 394 | so they cannot be copied into another device. |
| 395 | |
| 396 | The mobile page can list/create threads, send prompts, follow live SSE events, |
| 397 | steer or interrupt an active turn, and resolve normal tool approvals through |
| 398 | `POST /v1/approvals/{approval_id}`. It is still a local/LAN convenience surface: |
| 399 | do not expose it directly to the public internet without TLS and a trusted |
| 400 | fronting layer. |
| 401 | |
| 402 | ### Endpoints |
| 403 | |
| 404 | **Health** |
| 405 | - `GET /health` |
| 406 | |
| 407 | **Sessions** (durable session manager) |
| 408 | - `GET /v1/sessions?limit=50&search=<fuzzy>&include_archived=false&archived_only=false&workspace=<path>&sort=recent|name|size` |
| 409 | - `GET /v1/sessions/summary?…` (same query params; projected row shape) |
| 410 | - `GET /v1/sessions/{id}` (add `?peek=true&entries=12` for a bounded, redacted |
| 411 | read-only peek instead of the full transcript) |
| 412 | - `PATCH /v1/sessions/{id}` (`{ "title"?: string, "archived"?: bool }`) |
| 413 | - `DELETE /v1/sessions/{id}` |
| 414 | - `POST /v1/sessions/{id}/resume-thread` |
| 415 | |
| 416 | Sessions and threads answer the same `include_archived` / `archived_only` pair |
| 417 | with the same meaning, and `search` is the same fuzzy match (title, id, |
| 418 | workspace — substring, then subsequence) the TUI session picker and the sidebar |
| 419 | Sessions rail use. All three surfaces run one projection |
| 420 | (`crates/tui/src/session_projection.rs`), so a listing cannot differ between |
| 421 | the terminal and the dashboard. |
| 422 | |
| 423 | `GET /v1/sessions/summary` returns rows that are field-compatible with |
| 424 | `GET /v1/threads/summary` — `id`, `title`, `preview`, `model`, `mode`, |
| 425 | `workspace`, `archived`, `updated_at` — plus `message_count`, `total_tokens`, |
| 426 | `created_at`, `parent_session_id`, and `is_current`. One caveat stated plainly: |
| 427 | `preview` is the session's recorded **title**, not its last message. Session |
| 428 | metadata does not store a last message, and reading every transcript to |
| 429 | synthesise one would make a list view an unbounded read. Full transcript |
| 430 | preview lives in the TUI session picker, which reads one selected session. |
| 431 | |
| 432 | `PATCH /v1/sessions/{id}` renames and/or archives a saved session and returns a |
| 433 | lifecycle receipt shaped like the thread patch receipt: |
| 434 | |
| 435 | ```json |
| 436 | { |
| 437 | "session": { "id": "…", "title": "Renamed", "archived": true, "…": "…" }, |
| 438 | "changes": { "title": "Renamed", "archived": true } |
| 439 | } |
| 440 | ``` |
| 441 | |
| 442 | `changes` lists only what actually moved, so a no-op patch is distinguishable |
| 443 | from an applied one. Archiving is durable and reversible: an archived session |
| 444 | stays on disk and stays loadable, disappears from default listings, and is |
| 445 | never chosen by `--continue` or by auto-resume. The route is the same writer |
| 446 | the TUI picker (`e`) and `/sessions archive <id>` use — there is no second |
| 447 | archive notion. |
| 448 | |
| 449 | While a session is open in an interactive Codewhale process, that process holds |
| 450 | the authoritative copy in memory and rewrites the whole document on its next |
| 451 | autosave. `PATCH` therefore fails closed on it with `409 Conflict` rather than |
| 452 | writing something that would be silently reverted. Change it in the terminal |
| 453 | instead. A standalone `codewhale web` holds nothing open and is never blocked. |
| 454 | |
| 455 | `GET /v1/sessions/{id}?peek=true` returns a bounded, redacted, read-only view |
| 456 | instead of the transcript: at most 12 entries of at most 400 characters each |
| 457 | (`&entries=N` lowers the budget, never raises it past the cap), tool calls and |
| 458 | results summarised to a name and a size rather than inlined, and |
| 459 | credential-shaped substrings masked. `omitted_before` reports how many earlier |
| 460 | messages were dropped. The payload carries `"live": false` and deliberately has |
| 461 | no turn status, `running`, or `active` field — a saved session is a recording, |
| 462 | and live state comes only from a resumed thread's SSE stream. |
| 463 | |
| 464 | **Threads** (durable runtime data model) |
| 465 | - `GET /v1/threads?limit=50&include_archived=false&archived_only=false` |
| 466 | - `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false&archived_only=false` |
| 467 | - `POST /v1/threads` |
| 468 | - `GET /v1/threads/{id}` |
| 469 | - `PATCH /v1/threads/{id}` (see body shape below) |
| 470 | - `POST /v1/threads/{id}/resume` |
| 471 | - `POST /v1/threads/{id}/fork` |
| 472 | |
| 473 | `GET /v1/threads/summary` is the read-only summary surface used by the VS Code |
| 474 | Agent View. Each item includes `id`, `title`, `preview`, `model`, `mode`, |
| 475 | `archived`, `updated_at`, `latest_turn_id`, `latest_turn_status`, plus |
| 476 | workspace metadata: |
| 477 | |
| 478 | ```json |
| 479 | { |
| 480 | "id": "thread_...", |
| 481 | "title": "Implement MCP status count", |
| 482 | "preview": "The TUI footer should count project MCP servers...", |
| 483 | "model": "deepseek-v4-pro", |
| 484 | "mode": "agent", |
| 485 | "branch": "feature/runtime-api", |
| 486 | "head": "abc1234", |
| 487 | "dirty": false, |
| 488 | "workspace": "/Users/you/projects/codewhale", |
| 489 | "archived": false, |
| 490 | "updated_at": "2026-06-06T05:43:00Z", |
| 491 | "latest_turn_id": "turn_...", |
| 492 | "latest_turn_status": "completed" |
| 493 | } |
| 494 | ``` |
| 495 | |
| 496 | `branch` is resolved from the thread workspace at request time and may be |
| 497 | `null` when the workspace is not a Git repository or the branch cannot be read. |
| 498 | `head` is the current short Git commit for that workspace when available. |
| 499 | `dirty` is true when the workspace has staged, unstaged, or untracked changes. |
| 500 | `workspace` is included so editor clients can show when an agent lane is working |
| 501 | outside the current VS Code folder. |
| 502 | |
| 503 | Thread forks are sibling runtime threads, not an in-place tree projection. |
| 504 | `thread.forked` events include `source_thread_id`; internal backtrack-aware |
| 505 | forks may also include `backtrack_depth_from_tail` and `dropped_turn_id`. |
| 506 | Thread list and summary responses remain flat in v0.8.40, so clients that need |
| 507 | a graph should reconstruct it from events instead of assuming list order is a |
| 508 | complete tree. |
| 509 | |
| 510 | `archived_only=true` returns archived threads only (mutually overrides |
| 511 | `include_archived`). Default behavior is unchanged: `include_archived=false` |
| 512 | and `archived_only=false` returns active threads. Added in v0.8.10 (#563). |
| 513 | |
| 514 | `PATCH /v1/threads/{id}` body — every field is optional, missing means |
| 515 | "no change". At least one field must be present. `title` and `system_prompt` |
| 516 | accept an empty string to clear a previously-set value. Added in v0.8.10 (#562): |
| 517 | |
| 518 | ```json |
| 519 | { |
| 520 | "archived": true, |
| 521 | "allow_shell": false, |
| 522 | "trust_mode": false, |
| 523 | "auto_approve": false, |
| 524 | "model": "deepseek-v4-pro", |
| 525 | "mode": "agent", |
| 526 | "title": "User-set thread title", |
| 527 | "system_prompt": "You are a useful assistant." |
| 528 | } |
| 529 | ``` |
| 530 | |
| 531 | **Turns** (within a thread) |
| 532 | - `POST /v1/threads/{id}/turns` |
| 533 | - `POST /v1/threads/{id}/turns/{turn_id}/steer` |
| 534 | - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |
| 535 | - `POST /v1/threads/{id}/compact` (manual compaction) |
| 536 | |
| 537 | **Approvals** |
| 538 | - `POST /v1/approvals/{approval_id}` with body |
| 539 | `{ "decision": "allow" | "deny", "remember": false }` |
| 540 | |
| 541 | **User input** |
| 542 | - `POST /v1/user-input/{thread_id}/{input_id}` with body |
| 543 | `{ "answers": [{ "id": "question-id", "label": "Choice", "value": "Choice" }] }` |
| 544 | |
| 545 | Submitted values are delivered to the active model turn but are deliberately |
| 546 | excluded from durable Runtime items and events. The settled tool item contains |
| 547 | only a neutral receipt and a machine-readable `response_redacted` marker. The |
| 548 | Runtime accepts only an exact pending `(thread_id, input_id)` request; an |
| 549 | unknown, concurrently settling, or already settled id returns 404 and is never |
| 550 | placed in the engine mailbox. It commits the secret-free |
| 551 | `user_input.answered` receipt before removing the snapshot-authoritative prompt |
| 552 | or delivering the answer to the engine. That settlement runs independently of |
| 553 | the HTTP connection, so disconnecting after submission cannot leave a prompt |
| 554 | half accepted. Terminal-turn cancellation follows the same receipt-before- |
| 555 | removal ordering through `user_input.canceled`. |
| 556 | |
| 557 | **Client-executed dynamic tools** |
| 558 | - `POST /v1/threads/{thread_id}/turns/{turn_id}/tool-calls/{call_id}/result` |
| 559 | |
| 560 | The thread and turn in the result route must match the pending call. A call is |
| 561 | settled at most once; wrong-route and duplicate results return 404. Terminal |
| 562 | lifecycle events carry identifiers and status only, never tool result content. |
| 563 | The Runtime commits the terminal lifecycle event before making a submitted |
| 564 | result available to the model. Result delivery, timeout, and terminal-turn |
| 565 | cancellation race through one settlement owner, so exactly one of these events |
| 566 | is durable for a call: |
| 567 | |
| 568 | - `tool_call.requested` — the typed client-executed call became pending; |
| 569 | - `tool_call.resolved` — a result was durably accepted by the Runtime |
| 570 | (`result_accepted: true`; `success` is result metadata, but result content is |
| 571 | excluded); |
| 572 | - `tool_call.timeout` — no result won before the bounded wait expired; |
| 573 | - `tool_call.canceled` — the turn terminated before a submitted result won. |
| 574 | |
| 575 | HTTP `202 Accepted` and `tool_call.resolved` share that durable-acceptance |
| 576 | meaning. Neither claims that the model consumed the result: a concurrent turn |
| 577 | shutdown may close the model receiver after acceptance. Once the Runtime has |
| 578 | accepted the result, that call is terminal and a duplicate result returns 404. |
| 579 | |
| 580 | **Events** (SSE replay + live stream) |
| 581 | - `GET /v1/threads/{id}/events?since_seq=<u64>` |
| 582 | |
| 583 | Durable history parsing runs off the async server workers and reaches SSE in |
| 584 | bounded batches of at most 256 events through a backpressured channel. Broadcast |
| 585 | delivery is only a wake-up optimization: a lagged receiver opens the same |
| 586 | bounded durable replay from its last accepted cursor. Optional `replay_limit` |
| 587 | returns the newest requested tail and may not exceed 4096; `previous_seq` on |
| 588 | the first returned event advances past exactly the omitted history. |
| 589 | |
| 590 | **Snapshots** (read-only side-git restore point listing) |
| 591 | - `GET /v1/snapshots?limit=20` |
| 592 | |
| 593 | `/v1/snapshots` lists recent side-git restore points for the runtime workspace. |
| 594 | It is read-only and does not restore files. `limit` defaults to `20` and must be |
| 595 | between `1` and `100`. |
| 596 | |
| 597 | ```json |
| 598 | [ |
| 599 | { |
| 600 | "id": "snap_...", |
| 601 | "label": "post-turn:1", |
| 602 | "timestamp": 1780730580 |
| 603 | } |
| 604 | ] |
| 605 | ``` |
| 606 | |
| 607 | Runtime API restore/retry/undo/editor-apply mutation endpoints are intentionally |
| 608 | deferred. GUI clients should treat thread summaries and snapshots as inspection |
| 609 | surfaces until atomic filesystem + conversation-state mutation semantics are |
| 610 | specified and tested. |
| 611 | |
| 612 | **Receipts** (future read-only audit export) |
| 613 | - Proposed only: `GET /v1/threads/{thread_id}/turns/{turn_id}/receipt` |
| 614 | |
| 615 | **Compatibility stream** (one-shot, backwards-compatible) |
| 616 | - `POST /v1/stream` |
| 617 | |
| 618 | **Tasks** (durable background work) |
| 619 | - `GET /v1/tasks` |
| 620 | - `POST /v1/tasks` |
| 621 | - `GET /v1/tasks/{id}` |
| 622 | - `POST /v1/tasks/{id}/cancel` |
| 623 | |
| 624 | **Automations** (scheduled recurring work) |
| 625 | - `GET /v1/automations` |
| 626 | - `POST /v1/automations` |
| 627 | - `GET /v1/automations/{id}` |
| 628 | - `PATCH /v1/automations/{id}` |
| 629 | - `DELETE /v1/automations/{id}` |
| 630 | - `POST /v1/automations/{id}/run` |
| 631 | - `POST /v1/automations/{id}/pause` |
| 632 | - `POST /v1/automations/{id}/resume` |
| 633 | - `GET /v1/automations/{id}/runs?limit=20` |
| 634 | |
| 635 | **Introspection** |
| 636 | - `GET /v1/workspace/status` |
| 637 | - `GET /v1/skills` |
| 638 | - `GET /v1/apps/mcp/servers` |
| 639 | - `GET /v1/apps/mcp/tools?server=<optional>` |
| 640 | |
| 641 | Skill activation toggles are persisted under a cross-process transaction lock. |
| 642 | Each mutation reloads and merges the latest exact-name state before an atomic |
| 643 | write, and `GET /v1/skills` refreshes that shared state so another Codewhale |
| 644 | process's successful toggle is visible without restarting the Runtime API. |
| 645 | |
| 646 | **Usage** (token/cost aggregation across threads) |
| 647 | - `GET /v1/usage?since=<rfc3339>&until=<rfc3339>&group_by=<day|model|provider|thread>` |
| 648 | |
| 649 | `since` / `until` are inclusive RFC 3339 timestamps and may be omitted (no |
| 650 | bound). `group_by` defaults to `day`. Buckets are sorted by ascending key. |
| 651 | Empty time ranges produce empty `buckets` (never a 404). Cost is computed via |
| 652 | the model→pricing map; turns whose model has no pricing entry contribute |
| 653 | tokens but `0.0` cost. Added in v0.8.10 (#564). |
| 654 | |
| 655 | ```json |
| 656 | { |
| 657 | "since": "2026-04-01T00:00:00Z", |
| 658 | "until": "2026-04-30T23:59:59Z", |
| 659 | "group_by": "day", |
| 660 | "totals": { |
| 661 | "input_tokens": 12345, |
| 662 | "output_tokens": 6789, |
| 663 | "cached_tokens": 0, |
| 664 | "reasoning_tokens": 0, |
| 665 | "cost_usd": 0.012, |
| 666 | "turns": 42 |
| 667 | }, |
| 668 | "buckets": [ |
| 669 | { |
| 670 | "key": "2026-04-30", |
| 671 | "input_tokens": 1234, |
| 672 | "output_tokens": 678, |
| 673 | "cached_tokens": 0, |
| 674 | "reasoning_tokens": 0, |
| 675 | "cost_usd": 0.001, |
| 676 | "turns": 3 |
| 677 | } |
| 678 | ] |
| 679 | } |
| 680 | ``` |
| 681 | |
| 682 | ## Provider and model selection |
| 683 | |
| 684 | These three routes are how a GUI renders a model picker whose contents are true |
| 685 | for *this* runtime instead of guessed from a version snapshot. They were |
| 686 | undocumented until 2026-08-04, which cost a desktop integration a day: the |
| 687 | client probed `/v1/models`, `/v1/runtime/models`, and `/v1/runtime/providers` |
| 688 | (all correctly 404) and concluded the capability did not exist. |
| 689 | |
| 690 | ### `GET /v1/providers` |
| 691 | |
| 692 | ```json |
| 693 | { |
| 694 | "current": "modelstudio-token-plan", |
| 695 | "providers": [ |
| 696 | { |
| 697 | "id": "modelstudio-token-plan", |
| 698 | "display_name": "Alibaba Cloud Model Studio", |
| 699 | "default_base_url": "https://…/compatible-mode/v1", |
| 700 | "default_model": "qwen3.8-max", |
| 701 | "has_model_catalog": true, |
| 702 | "env_vars": ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY"] |
| 703 | } |
| 704 | ] |
| 705 | } |
| 706 | ``` |
| 707 | |
| 708 | `current` is the active provider id. Treat `default_base_url` and `env_vars` as |
| 709 | runtime-local detail: they are endpoint and credential *names*, and a browser |
| 710 | layer has no use for either. Project only `id`, `display_name`, |
| 711 | `default_model`, and `has_model_catalog` across a UI bridge. |
| 712 | |
| 713 | There is deliberately no credential-presence field yet — this route reports what |
| 714 | the runtime can *represent*, not what it can currently serve. A client that |
| 715 | needs "is this route usable here" can call the models route below and treat an |
| 716 | empty list as unusable, at the cost of one request per provider. |
| 717 | |
| 718 | ### `GET /v1/providers/{id}/models` |
| 719 | |
| 720 | ```json |
| 721 | { "provider": "modelstudio-token-plan", "models": [{ "id": "qwen3.8-max" }] } |
| 722 | ``` |
| 723 | |
| 724 | The catalog for one provider. Returns `400` for an unknown id, and for the |
| 725 | legacy `deepseek-cn` alias, which has no provider metadata — use `deepseek`. |
| 726 | An empty `models` array means the provider is not configured on this machine |
| 727 | (no credential), not that the provider has no models. |
| 728 | |
| 729 | The ids returned here are exactly the values accepted by `POST /v1/threads`'s |
| 730 | `model` field and by the switch route below. |
| 731 | |
| 732 | ### `POST /v1/providers/{id}/switch` |
| 733 | |
| 734 | ```json |
| 735 | // request (model is optional; omit to take the provider default) |
| 736 | { "model": "qwen3.8-max" } |
| 737 | |
| 738 | // response |
| 739 | { "provider": "modelstudio-token-plan", "model": "qwen3.8-max", |
| 740 | "message": "…", "persisted": true } |
| 741 | ``` |
| 742 | |
| 743 | **Use this rather than simulating a switch with repeated `POST /v1/config` |
| 744 | writes plus a reload.** Provider and model move together here, the change is |
| 745 | validated against the provider's catalog before it is applied, and `persisted` |
| 746 | reports whether it was written to config or applied to the live session only. |
| 747 | Rejects an unknown provider id and the `deepseek-cn` alias with `400`. |
| 748 | |
| 749 | ## Runtime data model |
| 750 | |
| 751 | The runtime uses a durable Thread/Turn/Item lifecycle. |
| 752 | |
| 753 | - **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`, `workspace`, |
| 754 | `mode`, `task_id`, `system_prompt`, `latest_turn_id`, |
| 755 | `latest_response_bookmark`, `archived` |
| 756 | - **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed| |
| 757 | failed|interrupted|canceled`), `effective_provider`, `effective_model`, |
| 758 | `effective_billing_surface`, timestamps, duration, usage, error summary |
| 759 | - **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message| |
| 760 | tool_call|file_change|command_execution|context_compaction|status|error`), |
| 761 | lifecycle `status`, `metadata` |
| 762 | |
| 763 | Events are append-only with a global monotonic `seq` for replay/resume. |
| 764 | |
| 765 | `effective_billing_surface` is a non-secret classification derived from the |
| 766 | endpoint that served the turn. Recognized StepFun routes use `stepfun-payg` or |
| 767 | `stepfun-plan`; unknown and custom endpoints leave it unset. The raw base URL is |
| 768 | not persisted in `TurnRecord`. |
| 769 | |
| 770 | ### Restart semantics |
| 771 | |
| 772 | - If the process restarts while a turn or item is `queued` or `in_progress`, |
| 773 | the recovered record is marked `interrupted` with an `"Interrupted by |
| 774 | process restart"` error. |
| 775 | - The trailing newline is an event append's commit marker. On startup, a final |
| 776 | JSONL fragment without that delimiter is truncated and fsynced even when its |
| 777 | bytes form valid JSON; it is an uncommitted append, and its already-reserved |
| 778 | sequence number is not reused. Newline-terminated malformed records are not |
| 779 | identifiable crash debris and continue to fail closed during replay. |
| 780 | - If a terminal turn record reached disk but its terminal event sequence did |
| 781 | not, the first async read reconciles any unresolved dynamic calls as |
| 782 | `tool_call.canceled` and then emits one `turn.completed`. Existing terminal |
| 783 | call and turn receipts are detected and never duplicated. |
| 784 | - Task execution performs its own recovery on top of the same persisted |
| 785 | thread/turn store. |
| 786 | |
| 787 | ### Approval model |
| 788 | |
| 789 | - The `auto_approve` flag applies to the runtime approval bridge and engine |
| 790 | tool context. When enabled for a thread/turn/task, approval-required tools |
| 791 | are auto-approved in the non-interactive runtime path, shell safety checks |
| 792 | run in auto-approved mode, and spawned sub-agents inherit that setting. |
| 793 | - When omitted, `auto_approve` defaults to `false`. |
| 794 | - [Authorization order](AUTHORIZATION_ORDER.md) describes where typed rules, |
| 795 | registered tool requirements, safety floors, repository law, approval |
| 796 | transport, and sandbox enforcement sit relative to one another. |
| 797 | |
| 798 | ### SSE event stream |
| 799 | |
| 800 | The SSE event payload shape for `/v1/threads/{id}/events`: |
| 801 | |
| 802 | ```json |
| 803 | { |
| 804 | "schema_version": 1, |
| 805 | "seq": 42, |
| 806 | "previous_seq": 38, |
| 807 | "event": "item.delta", |
| 808 | "kind": "item.delta", |
| 809 | "thread_id": "thr_1234abcd", |
| 810 | "turn_id": "turn_5678efgh", |
| 811 | "item_id": "item_90ab12cd", |
| 812 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 813 | "created_at": "2026-02-11T20:18:49.123Z", |
| 814 | "payload": { |
| 815 | "delta": "partial output", |
| 816 | "kind": "agent_message" |
| 817 | } |
| 818 | } |
| 819 | ``` |
| 820 | |
| 821 | Compatibility notes: |
| 822 | |
| 823 | - `schema_version` is the HTTP/SSE envelope schema version. It is independent of |
| 824 | the runtime store schema used for persisted thread/turn/event records. |
| 825 | - `event` remains the SSE event name in existing clients; it is preserved as-is. |
| 826 | - `kind` mirrors `event` in the stable envelope for typed clients. |
| 827 | - `seq` is allocated globally across all Runtime threads. Consequently, gaps |
| 828 | between a thread's events are normal when other threads interleave. On this |
| 829 | per-thread SSE stream, `previous_seq` is the sequence of the last event |
| 830 | delivered for this thread (or the requested replay cursor for the first |
| 831 | event); clients detect loss by comparing it with their accepted per-thread |
| 832 | cursor, not by requiring `seq == previous_seq + 1`. Sequence allocation is |
| 833 | also not rewound after an append is transactionally rolled back, so a retry |
| 834 | can intentionally skip an unused value without implying a missing event. |
| 835 | - `thread.started`, `turn.started`, and `turn.completed` are emitted as SSE event |
| 836 | names exactly as before. |
| 837 | - `timestamp` remains the canonical event time for schema version 1. `created_at` |
| 838 | is an equivalent alias for clients that use `created_at` naming elsewhere; do |
| 839 | not require both fields to be present. |
| 840 | |
| 841 | Common event names: `thread.started`, `thread.forked`, `turn.started`, |
| 842 | `turn.lifecycle`, `turn.steered`, `turn.interrupt_requested`, |
| 843 | `turn.completed`, `item.started`, `item.delta`, `item.completed`, |
| 844 | `item.failed`, `item.interrupted`, `approval.required`, `approval.decided`, |
| 845 | `approval.timeout`, `user_input.required`, `user_input.answered`, |
| 846 | `user_input.canceled`, `tool_call.requested`, `tool_call.resolved`, |
| 847 | `tool_call.timeout`, `tool_call.canceled`, `sandbox.denied`. |
| 848 | |
| 849 | Agent-message and reasoning deltas are materialized into the item projection |
| 850 | before their corresponding `item.delta` event is sequenced. To avoid an fsync |
| 851 | for every provider fragment, adjacent deltas are coalesced to configured bounds |
| 852 | of at most 32 ms or approximately 16 KiB before publication (an indivisible |
| 853 | upstream chunk can itself exceed the byte target). A process crash inside that |
| 854 | unpublished window can lose the recent suffix; no durable event claims that |
| 855 | suffix existed. Once an `item.delta` is durable, snapshots at or beyond its |
| 856 | cursor include the same materialized prefix. |
| 857 | |
| 858 | `approval.required` events may include a `matched_rule` string when an |
| 859 | execution-policy rule caused the prompt. This field is explanatory metadata for |
| 860 | clients and does not grant or persist permissions. |
| 861 | |
| 862 | ## Security boundary |
| 863 | |
| 864 | - **Localhost by default**. The server binds to `127.0.0.1` by default. |
| 865 | `--mobile` binds to `0.0.0.0` when no host is supplied so phones on the same |
| 866 | LAN can reach it, and the CLI prints a warning for that rebind. Pass |
| 867 | `--host 127.0.0.1` for a loopback-only mobile page. Set a non-loopback host |
| 868 | only when you trust the network path or have a reverse-proxy / VPN that |
| 869 | authenticates. The runtime does not provide user isolation or TLS. |
| 870 | - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN` |
| 871 | requires a matching bearer token for `/v1/*` routes. This is a local |
| 872 | convenience guard, not a replacement for TLS, VPN, or a trusted reverse |
| 873 | proxy on public networks. |
| 874 | - **No provider-token custody**. The server never returns the API key. The |
| 875 | `api_key.source` capability field reports `env`, `config`, or `missing` — |
| 876 | never the key itself. |
| 877 | - **No hosted relay**. The app-server is a local process under the user's |
| 878 | control. There is no cloud component. |
| 879 | - **Capability responses** never leak secrets, file contents, or session |
| 880 | message bodies. They report *metadata*: presence, counts, status flags. |
| 881 | |
| 882 | ### CORS allow-list |
| 883 | |
| 884 | The runtime API ships with a built-in dev-origin allow-list: |
| 885 | `http://localhost:3000`, `http://127.0.0.1:3000`, `http://localhost:1420`, |
| 886 | `http://127.0.0.1:1420`, `tauri://localhost`. To add additional origins (e.g. |
| 887 | when developing a UI on Vite's default `:5173`), use any of: |
| 888 | |
| 889 | - CLI flag (repeatable): `codewhale serve --http --cors-origin http://localhost:5173` |
| 890 | - Env var (comma-separated): `DEEPSEEK_CORS_ORIGINS="http://localhost:5173,http://localhost:8080"` |
| 891 | - Config (`~/.codewhale/config.toml`): |
| 892 | ```toml |
| 893 | [runtime_api] |
| 894 | cors_origins = ["http://localhost:5173"] |
| 895 | ``` |
| 896 | |
| 897 | User-supplied origins **stack on top of** the built-in defaults; they do not |
| 898 | replace them. Wildcard origins are not supported — the explicit allow-list |
| 899 | model is preserved. Cross-origin preflights advertise only `Authorization`, |
| 900 | `Content-Type`, `Accept`, `X-Codewhale-Runtime-Token`, and the compatibility |
| 901 | `X-DeepSeek-Runtime-Token` request header; custom request headers are not |
| 902 | allowed. Added in v0.8.10 (#561), tightened in v0.9.1 (#4454). |
| 903 | |
| 904 | ## Managed Fleet Runtime and SDK helpers |
| 905 | |
| 906 | The Runtime SDK lives in `npm/runtime-sdk` and is exposed as |
| 907 | the `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every |
| 908 | helper calls the local Rust Runtime API and therefore cannot bypass Codewhale's |
| 909 | sandbox, approval prompts, provider configuration, or fleet ledger authority. |
| 910 | |
| 911 | ```js |
| 912 | import { createRuntimeClient } from "@codewhale/runtime-sdk"; |
| 913 | |
| 914 | const client = createRuntimeClient({ |
| 915 | baseUrl: "http://127.0.0.1:7878", |
| 916 | token: process.env.CODEWHALE_RUNTIME_TOKEN, |
| 917 | }); |
| 918 | |
| 919 | const created = await client.createFleetRun({ |
| 920 | target: "this_computer", |
| 921 | roles: [{ name: "reviewer" }, { name: "verifier" }], |
| 922 | workflow: { |
| 923 | id: "release-check", |
| 924 | kind: "parallel", |
| 925 | tasks: [ |
| 926 | { id: "review", name: "Review", instructions: "Review locally.", worker: { role: "reviewer" } }, |
| 927 | { id: "verify", name: "Verify", instructions: "Verify locally.", worker: { role: "verifier" } }, |
| 928 | ], |
| 929 | }, |
| 930 | }); |
| 931 | |
| 932 | // POST /runs only prepares durable work. This call crosses the launch gate. |
| 933 | await client.startFleetRun(created.run.id); |
| 934 | |
| 935 | let cursor; |
| 936 | for await (const event of client.fleetEvents(created.run.id, { after: cursor })) { |
| 937 | if (event.cursor) cursor = event.cursor; |
| 938 | if (event.event === "fleet.replay.cursor_unavailable") { |
| 939 | // Reload getFleetRun(created.run.id), then reconnect without the old cursor. |
| 940 | } |
| 941 | } |
| 942 | ``` |
| 943 | |
| 944 | The managed path is deliberately two-step. `POST /v1/fleet/runs` validates and |
| 945 | persists the run and queue without starting a worker. A separate authenticated |
| 946 | `POST /start` activates it and schedules the executor driver; its `202` response |
| 947 | reports `leased: 0` because the driver performs all leasing after it owns the |
| 948 | run. Creation requires named roles, one task owner per role, a `parallel` |
| 949 | Workflow, and an explicit Runtime target. v0.9.4 executes |
| 950 | only `this_computer`; `another_computer` and `cloud` return `501` rather than |
| 951 | silently executing locally. Worker IDs are generated per run; caller-assigned |
| 952 | `worker_specs` return `501` until custom workers can be given collision-free |
| 953 | managed identities. Parallel tasks with overlapping effective write roots are |
| 954 | rejected before the run is journaled. Managed `security_policy` overrides also |
| 955 | fail closed until that document can be enforced end to end; executable |
| 956 | authority comes from each named role's tool posture and bounded task workspace |
| 957 | scope. |
| 958 | |
| 959 | Fleet helpers cover this HTTP surface: |
| 960 | |
| 961 | | Helper | Runtime API route | |
| 962 | |---|---| |
| 963 | | `createFleetRun(spec)` | `POST /v1/fleet/runs` | |
| 964 | | `startFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/start` | |
| 965 | | `listFleetRuns()` | `GET /v1/fleet/runs` | |
| 966 | | `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` | |
| 967 | | `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` | |
| 968 | | `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` | |
| 969 | | `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` | |
| 970 | | `stopWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/stop` | |
| 971 | | `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` | |
| 972 | | `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` | |
| 973 | | `replayFleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events/replay` | |
| 974 | | `fleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events` (SSE) | |
| 975 | |
| 976 | `stopWorker` durably cancels that worker's active task and leaves the rest of |
| 977 | the Fleet running. `interruptWorker` is the compatibility name for the same |
| 978 | attempt-fenced cancellation transition. `stopFleetRun` cancels every queued or |
| 979 | active task and marks the whole run cancelled. |
| 980 | |
| 981 | Replay covers aggregate run/task transitions and privacy-bounded individual |
| 982 | worker transitions. Event bodies omit prompts, tool call IDs, completion text, |
| 983 | artifact paths/checksums, and cancellation identities; bounded failure reasons |
| 984 | pass through secret redaction. `cursor` is opaque and stable across ordinary |
| 985 | appends and Runtime restarts. Clients reconnect with `after=<cursor>`. A fresh |
| 986 | request returns a bounded newest tail and marks `history_truncated` when older |
| 987 | history exists. Ledger compaction can remove an old cursor; the JSON endpoint |
| 988 | then returns `409`, while the SSE endpoint emits |
| 989 | `fleet.replay.cursor_unavailable`, so the client reloads the current run |
| 990 | projection instead of accepting a silent gap. |
| 991 | |
| 992 | `GET /v1/runtime/info` advertises `fleet_run_create`, `fleet_run_start`, |
| 993 | `fleet_event_replay`, `fleet_event_stream`, and `fleet_local_target`. Older |
| 994 | runtimes without a requested route still produce a typed SDK |
| 995 | `RuntimeCapabilityError`. |
| 996 | |
| 997 | Verification: |
| 998 | |
| 999 | ```bash |
| 1000 | npm test --workspace @codewhale/runtime-sdk |
| 1001 | ``` |
| 1002 | |
| 1003 | ## Agent Run Receipts |
| 1004 | |
| 1005 | Sub-agent lanes persist compact run receipts in |
| 1006 | `.codewhale/state/subagents.v1.json`. The Runtime API exposes those receipts as |
| 1007 | a read-only inspection surface: |
| 1008 | |
| 1009 | | Operation | Endpoint | |
| 1010 | |---|---| |
| 1011 | | List persisted agent runs | `GET /v1/agent-runs` | |
| 1012 | | Inspect one run | `GET /v1/agent-runs/{run_id}` | |
| 1013 | |
| 1014 | The response is the same worker-record shape surfaced by `agent` receipts: |
| 1015 | `spec.run_id`, `actor_kind`, lifecycle `status`, bounded `events`, |
| 1016 | `follow_up`, `takeover`, `artifacts`, `usage`, and `verification`. `run_id` |
| 1017 | falls back to the worker id for older records, and `{run_id}` may be either the |
| 1018 | run id or the worker id. |
| 1019 | |
| 1020 | These endpoints do not start, cancel, or steer sub-agents. The API surface |
| 1021 | exists so app/editor/headless clients can inspect the same handoff receipts that |
| 1022 | the TUI and parent model see. |
| 1023 | |
| 1024 | ## Session lifecycle (native UI supervision) |
| 1025 | |
| 1026 | | Operation | Endpoint | |
| 1027 | |---|---| |
| 1028 | | List sessions | `GET /v1/sessions` | |
| 1029 | | List session summaries | `GET /v1/sessions/summary` | |
| 1030 | | Get session | `GET /v1/sessions/{id}` | |
| 1031 | | Rename / archive session | `PATCH /v1/sessions/{id}` | |
| 1032 | | Delete session | `DELETE /v1/sessions/{id}` | |
| 1033 | | Resume into thread | `POST /v1/sessions/{id}/resume-thread` | |
| 1034 | | Create thread | `POST /v1/threads` | |
| 1035 | | List threads | `GET /v1/threads` | |
| 1036 | | Attach to events | `GET /v1/threads/{id}/events?since_seq=0` | |
| 1037 | | Send message | `POST /v1/threads/{id}/turns` | |
| 1038 | | Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` | |
| 1039 | | Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` | |
| 1040 | | Compact | `POST /v1/threads/{id}/compact` | |
| 1041 | |
| 1042 | ## Compatibility tests |
| 1043 | |
| 1044 | Contract snapshots live in `crates/protocol/tests/`. Run: |
| 1045 | |
| 1046 | ```bash |
| 1047 | cargo test -p codewhale-protocol --test parity_protocol --locked |
| 1048 | ``` |
| 1049 | |
| 1050 | This validates that the app-server's event schema hasn't drifted from the |
| 1051 | documented contract. CI runs this on every push to `main` and on release tags. |
| 1052 | |
| 1053 | The app-server stdio control surface has its own drift guard — the advertised |
| 1054 | `capabilities` method set is pinned in `crates/app-server/src/lib.rs`: |
| 1055 | |
| 1056 | ```bash |
| 1057 | cargo test -p codewhale-app-server capabilities |
| 1058 | ``` |
| 1059 | |
| 1060 | Before a release, run the headless smoke (stdio probe + optional provider |
| 1061 | matrix, no secrets leaked): |
| 1062 | |
| 1063 | ```bash |
| 1064 | scripts/release/app-server-smoke.sh --matrix # dry-run plan |
| 1065 | bash scripts/release/app-server-smoke.test.sh # parser self-test (fake binary) |
| 1066 | ``` |
| 1067 |