返回 CodeWhale
RUNTIME_API.md
根目录 / docs / RUNTIME_API.md
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 Codewhale 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 app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon)
32 ├─ codewhale doctor --json → machine-readable health & capability
33 ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed
34 ├─ codewhale serve --mcp → MCP stdio server
35 ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile`
36 └─ codewhale exec [args] → one-shot headless worker (stream-json)
37 ```
38
39 The engine runs as a local-only process. All APIs bind to `localhost` by
40 default. No hosted relay, no provider-token custody, no secret leakage.
41
42 For a proposed read-only audit export over completed turns, see
43 [`docs/RECEIPTS.md`](RECEIPTS.md). That document is a protocol note; the receipt
44 CLI/API surfaces are not implemented yet.
45
46 ## Runtime API entrypoints
47
48 | Entry | Transport | Use |
49 |---|---|---|
50 | `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser |
51 | `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) |
52 | `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page |
53 | `codewhale app-server --stdio` | JSON-RPC 2.0 over stdio | Local SDK / control probe (no listener) |
54 | `codewhale app-server --socket [--socket-path P]` | JSON-RPC 2.0 over a `0600` unix domain socket | Desktop daemon: multi-client, peer-uid checked, `daemon/attach` claim handshake (macOS/Linux; Windows named pipe reserved, not implemented) |
55 | `codewhale app-server` | HTTP on `127.0.0.1:8787` | Legacy in-process app-server (`/healthz`, `/thread`, `/app`, `/prompt`, `/tool`, `/jobs`); `/prompt` and `/thread` messages execute real turns via the runtime bridge |
56 | `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases |
57
58 `app-server --http` and `--mobile` launch the same mature runtime API server
59 historically reached through `serve --http` — no routes or behavior changed, so
60 every endpoint documented below is identical across both entrypoints. The
61 runtime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`,
62 then `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback
63 bind. The `serve` compatibility aliases keep their `--insecure` flag.
64 The legacy in-process `codewhale app-server` also requires an explicit
65 `--auth-token` or `CODEWHALE_APP_SERVER_TOKEN` before binding a non-loopback
66 host; its generated one-time `cwapp_*` token is loopback-only.
67
68 ### Workspace file suggestions
69
70 `GET /v1/workspace/files/search?query=runtime&limit=20` returns
71 `{"paths":["src/runtime.rs"]}` through the existing authenticated `/v1/*`
72 router. It searches only the server's configured workspace, not a thread's
73 workspace or the process's current directory. No workspace/path override is
74 accepted. The response contains workspace-relative file paths with `/`
75 separators, never file contents, absolute paths, or directories.
76
77 - `query` is a literal partial filename/path, without an `@` prefix, at most
78 256 UTF-8 bytes. Missing, empty, or whitespace-only queries return an empty
79 list without walking the filesystem. No match also returns an empty list.
80 - `limit` defaults to 20; accepted values are 1–100. Invalid limits, oversized
81 queries, and unknown query parameters return HTTP 400.
82 - Matching reuses TUI fuzzy `@file` discovery/ranking: case-insensitive path
83 prefix matches first, then substring matches, alphabetically within each
84 group. This is not glob, subsequence, content, or semantic search, and does
85 not apply the TUI's personal frecency boosts.
86 - Discovery shares the composer's ignore policy, including `.ignore` and
87 `.deepseekignore`, always-discoverable AI directories, and the bounded
88 hidden/gitignored local-reference fallback. The special `.agents`, `.claude`,
89 `.cursor`, and `.deepseek` walks intentionally bypass ignore rules, as in
90 the TUI. Ignore files are not confidentiality boundaries.
91 - Directory symlinks are not traversed. Files are canonicalized and filtered
92 for containment in the workspace before applying the result limit; external
93 and broken file symlinks are omitted. In-workspace file symlinks may appear
94 by their relative names. Suggestions are a filesystem snapshot, not
95 authorization to read a file later; consumers must revalidate when opening it.
96
97 Discovery runs off the async executor, with the shared default depth of 10,
98 at most 20,000 candidates, and a cooperative two-second discovery budget.
99 Results are best-effort, not an exhaustive listing; a slow filesystem operation
100 can finish after that budget. Each request scans anew; there is no new index or
101 cache. This read-only endpoint does not alter sessions or the pinned model
102 prompt/tool prefix.
103
104 ### Workspace files and session artifacts
105
106 Native clients (the GPUI desktop's Files and Preview modules) browse and edit
107 the server's configured workspace through three authenticated routes. They
108 read and write the workspace directly; there is no second file store, cache
109 or index, and no path override: the workspace root is the only root.
110
111 - `GET /v1/workspace/files?path=<dir>&limit=<1-2000>` lists one directory.
112 `path` is workspace-relative with `/` separators; empty or `.` is the root.
113 Each entry carries `name`, `path`, `kind` (`file`, `directory`, `symlink`,
114 `other`), and for files `size` and `modified` (RFC 3339). Directories sort
115 first, then names case-insensitively. `limit` defaults to 200; `truncated`
116 reports a cut. `.git` is never listed or served, and symlinks are listed by
117 name only: they are never followed, so `path=<link>` returns 403.
118 - `GET /v1/workspace/files/read?path=<file>&offset=<bytes>&limit=<1-4194304>`
119 returns one byte window of a regular file with `size`, `revision` (the
120 SHA-256 hex of the **whole** file, not of the window), `modified`,
121 `offset`, `bytes`, `truncated`, `encoding` and `content`. Text windows are
122 `utf-8`; a window with a NUL byte, invalid UTF-8, or a split multi-byte
123 character is `base64`. `limit` defaults to 256 KiB. Files above 16 MiB are
124 refused with 413; a directory is 400; a link is 403; a missing file is 404.
125 - `PUT /v1/workspace/files` with `{"path", "content", "encoding"?,
126 "expected_revision"?}` writes one file atomically through the same confined
127 opener Fleet artifacts use. `encoding` is `utf-8` (default) or `base64`;
128 bodies above 4 MiB are 413. Creating a new file requires **no**
129 `expected_revision` (and creates missing parent directories inside the
130 workspace); overwriting requires the `revision` from the read that the
131 edit was based on, and a stale or missing one is 409 with the current
132 revision in the error message so the client can re-read and merge. This is
133 optimistic concurrency, not a lock: two writers racing between the check and
134 the write can still interleave. The response carries `path`, `size`,
135 `revision`, `created` and `written_at`; 201 for a new file, 200 otherwise.
136 Writes through a link, into `.git`, or to a directory are refused.
137
138 Every path is validated before any filesystem access: absolute paths,
139 backslashes, `.` or `..` components are 400, and each directory on the way is
140 opened without following links (`O_NOFOLLOW` per component on Unix, reparse
141 point checks on Windows). These routes use the runtime bearer token like every
142 other `/v1/*` route; they do not consult the model's tool permission posture,
143 because the caller is the authenticated operator, not the model.
144
145 Session artifacts are the oversized tool outputs a session recorded as
146 `ArtifactRecord`s (`crates/tui/src/artifacts.rs`), stored under
147 `sessions/<id>/artifacts/`:
148
149 - `GET /v1/sessions/{id}/artifacts` lists the records a saved session carries:
150 `id`, `kind`, `tool_call_id`, `tool_name`, `created_at`, `byte_size`,
151 `preview` and the session-relative `path`.
152 - `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=` reads one
153 artifact with the same window, `revision` and `encoding` contract as the
154 workspace file read. A record whose stored path is absolute or leaves the
155 session directory is 403; a record whose file is gone is 404.
156
157 Fleet receipt artifacts keep their own route
158 (`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`).
159
160 ### Runtime and account identity
161
162 `GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character
163 `codewhale_commit` embedded by the shared CLI/TUI build. A source archive that
164 cannot provide an exact commit reports `unknown`, allowing compatibility
165 clients to fail closed rather than accepting an ambiguous binary pair.
166
167 The same response advertises `capabilities.account_session: true` and
168 `capabilities.turn_operation_idempotency: true`. A client must require the
169 latter before relying on `operation_key`; do not infer support from a 2xx turn
170 response because an older tolerant reader may ignore an unknown request field.
171 `capabilities.turn_operation_lookup: true` separately advertises the read-only
172 operation lookup below; clients must require it before relying on GET-based
173 recovery of a lost turn response.
174 The response also includes a token-free account receipt:
175
176 ```json
177 {
178 "account": {
179 "schema_version": 1,
180 "state": "authenticated",
181 "api_base": "https://api.codewhale.net",
182 "account_id": "acct_...",
183 "session_id": "session_...",
184 "scopes": [],
185 "expires_at": "2026-08-01T20:00:00Z"
186 }
187 }
188 ```
189
190 The Runtime reads this receipt from the exact profile- and API-origin-scoped
191 secure record written by `codewhale account login`; it does not run a second
192 login flow. States are `signed_out`, `authenticated`, `offline_cached`,
193 `expired`, or `revoked`. Scopes are copied only from explicit stored session
194 grants and are never inferred from account identity. Access/refresh tokens,
195 email, provider profile, and provider credentials are never returned.
196 `account_id` and `session_id` are included only for a request authorized with
197 the Runtime token (or an explicitly insecure loopback server); the public
198 bootstrap response remains usable but reports `signed_out`. Signed-out local
199 Work remains supported and never allocates cloud compute implicitly.
200
201 The `--stdio` control transport is newline-delimited JSON-RPC 2.0. Probe it
202 without spending model tokens:
203
204 ```bash
205 printf '%s\n' \
206 '{"jsonrpc":"2.0","id":1,"method":"healthz"}' \
207 '{"jsonrpc":"2.0","id":2,"method":"capabilities"}' \
208 '{"jsonrpc":"2.0","id":3,"method":"shutdown"}' \
209 | codewhale app-server --stdio
210 ```
211
212 `capabilities` returns the advertised method families (`thread/*`, `app/*`,
213 `prompt/*`) and the full method list; `thread/capabilities`,
214 `app/capabilities`, and `prompt/capabilities` scope it per family. The method
215 set is pinned by a drift test in `crates/app-server/src/lib.rs`, so SDK and
216 local integration clients can rely on it not changing silently.
217
218 ### Daemon socket: `codewhale app-server --socket`
219
220 The desktop shell (DESKTOP-APP-BRIEF §2) attaches to a long-lived daemon over
221 a unix domain socket. The wire is the `--stdio` transport verbatim — the same
222 newline-delimited JSON-RPC 2.0 methods, dispatched by the same code — with one
223 handshake in front of it.
224
225 > Note (2026-09-14): the Tauri desktop shell named above is retiring under the
226 > 2026-09-14 product-client transition, and the DESKTOP-APP-BRIEF reference is
227 > a dangling pointer (that brief does not exist in this repo). The GPUI client
228 > in the private `codehwhale-gpui` repo is the successor daemon consumer over
229 > this HTTP runtime API; the socket protocol described here is unchanged.
230
231 **Endpoint.** `--socket-path` if given; else `$CODEWHALE_HOME/run/daemon.sock`
232 when `CODEWHALE_HOME` is set (an explicit home is an isolation boundary); else
233 `$XDG_RUNTIME_DIR/codewhale/daemon.sock`; else
234 `~/Library/Application Support/codewhale/daemon.sock` on macOS or
235 `~/.codewhale/run/daemon.sock` elsewhere. The directory is created `0700`, the
236 socket is `0600`, and every accepted peer must present the daemon's own uid.
237 On start, a socket file nobody answers on is removed; a live one makes the new
238 daemon exit with `a live listener already answers on <path>; refusing to
239 replace it`; a non-socket file at the path is never touched. On Windows `--socket` fails with a typed
240 `UnsupportedPlatform` error naming the reserved pipe `\\.\pipe\codewhale-daemon`
241 — there is no silent TCP fallback. The daemon prints
242 `codewhale daemon: listening on <path>` to stderr once it is accepting.
243
244 **Handshake.** The first request on a connection must be `daemon/attach`
245 (`healthz` is also allowed beforehand, so a shell can probe liveness). Every
246 other method is refused with `-32010 attach_required` until then.
247
248 ```json
249 {"jsonrpc":"2.0","id":1,"method":"daemon/attach","params":{
250 "client":{"name":"codewhale-desktop","version":"1.2.3","pid":4242},
251 "mode":"claim",
252 "expect_daemon_version":"0.9.11"}}
253 ```
254
255 `mode` is `"claim"` (this client spawned the daemon and manages its lifetime)
256 or `"attach"` (default: a guest that found a healthy daemon). A claim while
257 another connection owns the daemon fails with `-32011 daemon_already_claimed`
258 (`data.owner` names the holder) and the client should retry with `attach`.
259 `expect_daemon_version`, when present, must equal the daemon's crate version
260 or the attach fails with `-32013 daemon_version_skew` (the bundle-skew guard).
261 The reply reports the granted `role` (`owner` / `attached`), the daemon's
262 `pid`, `version`, `socket_path`, and `uptime_ms`, the current `owner`, and the
263 live `connections` count. A second `daemon/attach` on an attached connection
264 is `-32014 already_attached`.
265
266 **Capabilities.** On this transport `capabilities.methods` is the pinned stdio
267 set plus `daemon/attach` (second entry, after `healthz`); `transport` reads
268 `unix-socket`. `shutdown` is advertised to every connection because the method
269 exists, but only the owner may call it (below).
270
271 **Ownership.** Only the owner may `shutdown`; a guest's `shutdown` is refused
272 with `-32012 not_daemon_owner` and does not interrupt anyone's turn. When the
273 owner disconnects the slot frees, so a relaunched shell re-claims the daemon it
274 left running. The owner's `shutdown` stops the listener, closes every
275 connection, and removes the socket file. Journal replay from a client's
276 last-seen `seq` is not part of this transport yet.
277
278 ### Interrupting a turn
279
280 `thread/message` streams until the turn reaches a terminal state, which can
281 take minutes. The read loop keeps polling stdin while a turn streams, so a
282 client can send:
283
284 ```json
285 {"jsonrpc":"2.0","id":9,"method":"thread/interrupt","params":{"thread_id":"thr_..."}}
286 ```
287
288 and the runtime is asked to interrupt that turn
289 (`POST /v1/threads/{id}/turns/{turn_id}/interrupt`). The reply carries
290 `interrupted: false` when no turn is streaming for that thread — this is not
291 an error, just nothing to stop. The interrupted `thread/message` then fails
292 with a `turn interrupted` error, and its reply is written before the
293 interrupt's own reply, since the turn owns the writer until it unwinds.
294
295 `shutdown` sent during a live turn also interrupts first: it needs the same
296 bridge that the turn holds, so without that it would wait for the very turn
297 it was meant to stop. Other requests that arrive mid-turn are queued and run
298 in order once the turn finishes.
299
300 ### Running a prompt
301
302 `prompt/request` and `prompt/run` (byte-identical aliases) and the legacy
303 HTTP `POST /prompt` all execute a **real turn** on the runtime, through the
304 same bridge `thread/message` uses. There is no local fallback: nothing else
305 in the app-server can produce model output, so a prompt either runs or fails.
306
307 - `params.prompt` is required and must be non-empty (`-32602` otherwise).
308 - `params.thread_id` is optional. With one, the prompt runs on that thread and
309 its history. Without one, the runtime gets a fresh thread for that single
310 turn; the mapping is dropped when the turn ends, so a one-shot prompt is not
311 addressable by `thread/interrupt`. Use `thread/message` when you need to be
312 able to interrupt.
313 - `params.model` selects the model only when the call is the one that creates
314 the runtime thread; an existing thread keeps the model it was created with.
315 - The response carries what the model actually said: `output` is the
316 concatenated `agent_message` text, `model` is the model the runtime reports
317 for the thread that ran it, and `events` are the real
318 `response_start`/`response_delta`/`response_end` frames. Over stdio the same
319 frames are also streamed to stdout while the turn runs, exactly as for
320 `thread/message`.
321 - If the runtime cannot be reached, the call fails with `-32005`
322 (`runtime_unavailable`) on stdio, or HTTP `503` with
323 `{"error":{"code":"runtime_unavailable", ...}}` on `POST /prompt`. Failures
324 are never shaped like a successful `PromptResponse`.
325
326 `POST /thread` with a `Message` body behaves the same way — it runs the turn
327 and replies `status: "completed"` with the streamed frames in `events` — where
328 it previously replied `accepted` without doing anything.
329
330 ### Answering a clarification question
331
332 When a headless turn calls `request_user_input`, the runtime emits a
333 `user_input.required` event carrying a `request_id`. Reply on the runtime API:
334
335 ```
336 POST /v1/user-input/{thread_id}/{request_id}
337 ```
338
339 The app-server control transport cannot accept that reply.
340 `app/request` with `SubmitUserInput` returns `ok: false` and
341 `error: "user_input_reply_unsupported"`. This is a property of the transport,
342 not an omission: while a turn is streaming, the stdio loop executes only
343 `thread/interrupt` and queues everything else, so an answer sent there would
344 wait on the very turn that is waiting for it.
345
346 ## SDK contract
347
348 The app-server exists so an external SDK can answer — without scraping TUI
349 output — *what route ran, which provider/model/reasoning/permission profile was
350 effective, what events happened, how many tokens were used, and how the run
351 finished.* The durable Thread/Turn/Item data model already carries most of
352 this; the table maps each integration need to where a local client reads it.
353
354 | Integration need | Where it comes from | Status |
355 |---|---|---|
356 | Route / effective model / billing surface | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available |
357 | Permission / sandbox / approval profile | thread `auto_approve`, sandbox + approval policy; `TurnRecord.permission_posture` + `TurnRecord.mode` for how *that* run was governed (the thread's own `mode` may have been switched since) | available |
358 | Run / thread / turn IDs | `thread_id`, `turn_id`, SSE event envelope | available |
359 | Event stream | `GET /v1/threads/{id}/events` (replay + live SSE) | available |
360 | Turn status / terminal classification | `TurnRecord.status` + error summary | available |
361 | Token usage | `TurnRecord.usage`; aggregate via `GET /v1/usage` | available |
362 | Single-read run receipt (route + usage + cost) | `GET /v1/threads/{id}/turns/{turn_id}/receipt` | proposed ([RECEIPTS.md](RECEIPTS.md)) |
363
364 For one-shot/headless automation, prefer `codewhale exec` with explicit
365 `--provider <id> --model <id>` so a failure identifies the exact provider/model
366 pair. Use `app-server` when a local integration needs to start, resume, steer,
367 or interrupt turns, list models/capabilities, follow the event stream, or read
368 usage. Both paths share the same runtime, so route-effective model resolution
369 and the event vocabulary match.
370
371 ### Release smoke
372
373 `scripts/release/app-server-smoke.sh` is the committed pre-release check:
374
375 ```bash
376 scripts/release/app-server-smoke.sh # stdio health/capabilities probe (no tokens)
377 scripts/release/app-server-smoke.sh --matrix # + print the configured provider/model matrix
378 scripts/release/app-server-smoke.sh --matrix --real # + exec a cheap sentinel per provider
379 ```
380
381 The stdio probe runs against a throwaway config, so it never reads real keys.
382 The matrix discovers configured providers from `codewhale auth list`, skips
383 unconfigured providers, and maps a provider to a cheap sentinel model only when
384 it has a built-in cheap default. That built-in set is deliberately conservative
385 (currently `deepseek`, `zai`, `moonshot`, and `openai`); every other provider —
386 including `arcee`, `openrouter`, `xiaomi-mimo`, and `openai-codex` — is left
387 unmapped on purpose and must be given a model per run via `SMOKE_MODEL_<SLUG>`
388 rather than a guessed default (#3205). Any configured-but-unmapped provider
389 fails loudly in `--real` mode. `auth list` reports presence flags only and exec
390 output is passed through a redactor, so secrets are never printed. The parser is
391 covered by `scripts/release/app-server-smoke.test.sh` against a fake `codewhale`
392 binary.
393
394 ## ACP stdio adapter: `codewhale serve --acp`
395
396 `codewhale serve --acp` speaks JSON-RPC 2.0 over newline-delimited stdio for
397 ACP-compatible editor clients. The initial adapter implements the ACP baseline:
398
399 - `initialize`
400 - `session/new`
401 - `session/prompt`
402 - `session/cancel`
403
404 Prompt requests are routed through the configured Codewhale client and current
405 default model. Responses are emitted as `session/update` agent message chunks
406 followed by a `session/prompt` response with `stopReason: "end_turn"`.
407
408 Each session executes tool calls locally through a registry built from the
409 same file/search/git/patch/shell tools as the CLI exec agent, gated by
410 `session/request_permission` and reported as `tool_call` / `tool_call_update`
411 session updates. What ACP sessions still lack is the full thread/turn
412 runtime: no durable threads, snapshots, steering, or approval parity with
413 `/v1/*` (tracked by #5835). Use `codewhale serve --http` for the full local
414 runtime API and `codewhale serve --mcp` when another client needs
415 Codewhale's tools as MCP tools.
416
417 ## Capability endpoint: `codewhale doctor --json`
418
419 Returns a JSON object describing the current installation's readiness state.
420 Suitable for health-check polling from a macOS workbench. This command is
421 strictly structural and offline: it does not load workspace credential
422 `.env` files, inspect credential environment values, open secret/OAuth files,
423 probe an OS keyring, contact providers, or start MCP processes.
424
425 ```bash
426 codewhale doctor --json
427 ```
428
429 ### Response schema (key fields)
430
431 | Field | Type | Description |
432 |---|---|---|
433 | `version` | string | Installed version (e.g. `"0.8.9"`) |
434 | `config_path` | string | Resolved config file path |
435 | `config_present` | bool | Whether the config file exists |
436 | `paths` | object | Canonical config, settings, state, sessions, logs, automations, and secrets paths |
437 | `secret_backend` | object | Metadata-only file-store shape, or literal `unknown` / `not_probed` for system and unsupported backends |
438 | `workspace` | string | Default workspace directory |
439 | `legacy_state.primary_root` | string | Primary Codewhale state root inspected for known state paths |
440 | `legacy_state.legacy_root` | string | Legacy `.deepseek` state root inspected for known state paths |
441 | `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 |
442 | `legacy_state.legacy_only_count` | number | Count of known state paths present only under the legacy root |
443 | `legacy_state.dual_present_count` | number | Count of known state paths present under both primary and legacy roots |
444 | `legacy_state.entries` | array | Per-path migration status: `{name, primary_present, legacy_present, status}` |
445 | `legacy_state.session_recovery.status` | string | `isolated`, `no_legacy_sessions`, `migration_pending`, `migration_incomplete`, `migration_complete`, or `scan_failed` |
446 | `legacy_state.session_recovery.read_only` | bool | Always true; doctor never invokes session migration or modifies either session directory |
447 | `legacy_state.session_recovery.chat_contents_read` | bool | Always false; comparison is based only on top-level `.json` filenames and filesystem metadata |
448 | `legacy_state.session_recovery.checkpoint_internals_scanned` | bool | Always false; `sessions/checkpoints/` and all other directories are skipped |
449 | `legacy_state.session_recovery.recoverable_files` | array | Bounded sample of up to 100 missing destination filenames with source and destination paths; no chat payloads |
450 | `legacy_state.session_recovery.recoverable_file_count` | number | Total missing destination filename count, including entries beyond the bounded sample |
451 | `legacy_state.session_recovery.recoverable_files_truncated` | bool | Whether more than 100 recoverable filenames were found |
452 | `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 |
453 | `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 |
454 | `api_key.availability` | string | Literal `present`, `not_required`, `not_probed`, `unavailable`, or `unknown`; only `present` and `not_required` certify structural Setup/Fleet credential readiness |
455 | `base_url` | string | Provider URL authority only (`scheme://host[:explicit-port]`); userinfo, path, query, and fragment are omitted |
456 | `default_text_model` | string | Default model |
457 | `memory.enabled` | bool | Whether the memory feature is on |
458 | `memory.path` | string | Path to memory file |
459 | `memory.file_present` | bool | Whether memory file exists |
460 | `mcp.config_path` | string | MCP config file path |
461 | `mcp.present` | bool | Whether MCP config exists |
462 | `mcp.probe_scope` | string | `configuration`; doctor does not start MCP servers |
463 | `mcp.live_health_checked` | bool | Always false for doctor JSON |
464 | `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` |
465 | `skills.selected` | string | Resolved skills directory |
466 | `skills.global.path` / `.present` / `.count` | — | Codewhale global skills dir (`~/.codewhale/skills`, with legacy `~/.deepseek/skills` support) |
467 | `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir |
468 | `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) |
469 | `skills.local.path` / `.present` / `.count` | — | `skills/` dir |
470 | `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir |
471 | `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir |
472 | `tools.path` / `.present` / `.count` | — | Global tools directory |
473 | `plugins.path` / `.present` / `.count` | — | Global plugins directory |
474 | `sandbox.available` | bool | Whether sandbox is supported on this OS |
475 | `sandbox.kind` | string or null | Sandbox kind (e.g. `"macos_seatbelt"`) |
476 | `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir |
477 | `storage.stash.path` / `.present` / `.count` | — | Composer stash |
478
479 ### Example
480
481 ```json
482 {
483 "version": "0.8.9",
484 "config_path": "/Users/you/.codewhale/config.toml",
485 "config_present": true,
486 "workspace": "/Users/you/projects/codewhale-tui",
487 "api_key": {
488 "source": "secret_store_unprobed",
489 "availability": "not_probed"
490 },
491 "base_url": "https://api.deepseek.com",
492 "default_text_model": "deepseek-v4-pro",
493 "memory": {
494 "enabled": false,
495 "path": "/Users/you/.codewhale/memory.md",
496 "file_present": true
497 },
498 "mcp": {
499 "config_path": "/Users/you/.codewhale/mcp.json",
500 "present": true,
501 "servers": [
502 {"name": "filesystem", "enabled": true, "transport": "stdio", "args_count": 2, "env_count": 0, "status": "ok"}
503 ]
504 },
505 "sandbox": {
506 "available": true,
507 "kind": "macos_seatbelt"
508 }
509 }
510 ```
511
512 ## HTTP/SSE runtime API: `codewhale app-server --http`
513
514 ```bash
515 codewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth]
516 codewhale app-server --mobile [--host 127.0.0.1] [--port 7878] [--auth-token TOKEN]
517 codewhale app-server --mobile --host ::1 [--port 7878] [--insecure-no-auth]
518 codewhale web [--port 7878]
519
520 # Compatibility aliases — identical server, serve flag names:
521 codewhale serve --http [...] [--insecure]
522 codewhale serve --mobile [...] [--insecure]
523 ```
524
525 Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8).
526
527 The server binds to `localhost` by default. Configuration is via CLI flags —
528 there is no `[app_server]` config section.
529
530 `/v1/*` routes require a bearer token unless `codewhale app-server` is started
531 with `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Mobile mode
532 is loopback-only: non-loopback hosts are rejected until Runtime has a TLS or
533 verified-overlay transport boundary. The `codewhale serve` compatibility aliases
534 use `--insecure` for the same loopback escape hatch.
535 Pass `--auth-token TOKEN` or set `CODEWHALE_RUNTIME_TOKEN=TOKEN` before starting
536 the server; `DEEPSEEK_RUNTIME_TOKEN` remains a compatibility alias. If neither
537 is set, the process generates a Runtime token for that process and does **not**
538 print it. `/health`, `/v1/runtime/info`, and an enabled static client shell
539 remain public; Runtime mutations and thread data stay behind `/v1/*`
540 authentication. `/mobile` returns 404 when mobile mode is disabled and serves
541 the unchanged static shell when it is enabled.
542
543 Authenticated clients can provide the token as `Authorization: Bearer TOKEN`,
544 `X-Codewhale-Runtime-Token: TOKEN`, the legacy
545 `X-DeepSeek-Runtime-Token: TOKEN`. Query-string and raw Runtime-token cookie
546 authentication are not supported.
547
548 ### Local browser client
549
550 `codewhale web` starts the canonical Runtime API on `127.0.0.1`, serves
551 dependency-free assets embedded in the binary, prints a single-use launch URL,
552 and asks the operating system to open that URL in the default browser. If the
553 browser does not open, the printed URL remains usable for ten minutes. The
554 command cannot bind to a non-loopback host and cannot run with Runtime auth
555 disabled.
556
557 The browser-launch URL contains a random, short-lived, one-time bootstrap
558 capability, never the Runtime token. A loopback request exchanges that
559 capability for a
560 `codewhale_web_session=…; HttpOnly; SameSite=Strict; Path=/` cookie backed by a
561 single process-local server session that expires 12 hours after the server
562 process starts, consumes the capability immediately, and redirects to `/`.
563 Reused, expired, malformed, or
564 non-loopback bootstrap attempts fail closed. The Runtime bearer token is not
565 placed in rendered HTML, browser storage, logs, URL queries/fragments, or
566 browser-launch arguments. The one-time bootstrap capability is printed in the
567 local terminal and transits the OS browser launcher's argument list. A same-user
568 process could race the browser to the exchange, which is why the capability is
569 single-use, loopback-only, and expires after ten minutes — and why a same-user
570 attacker has strictly easier local avenues than this race.
571 Existing bearer/header/cookie authorization for `/v1/*` is unchanged outside
572 web mode. In web mode, cookie-authenticated unsafe requests must also carry the
573 exact local web origin, and Fetch Metadata identifying a cross-origin cookie
574 request is rejected. Explicit bearer and Runtime-token header clients keep
575 their existing behavior.
576
577 The embedded client provides a responsive thread/search rail, Runtime-owned
578 session facts, transcript and tool receipts, and a bottom composer. It can
579 create, select, rename, and archive threads; choose a provider and model for a
580 new thread without changing Runtime defaults; start or steer turns; interrupt
581 work; resolve approvals; and answer Runtime user-input requests. Selection
582 loads `GET /v1/threads/{id}` first, then opens the replayable event stream with
583 `since_seq=latest_seq`; reconnection advances from the newest accepted sequence
584 and drops duplicates or events from a stale selection. The thread detail
585 snapshot includes `pending_approvals`, `pending_user_inputs`, and
586 `pending_dynamic_tool_calls`; clients must hydrate those fields before
587 subscribing so a reload cannot strand work whose request event is at or before
588 `latest_seq`. Resolution is also published as `approval.decided`,
589 `user_input.answered`, `user_input.canceled`, `tool_call.resolved`,
590 `tool_call.canceled`, or `tool_call.timeout` for already-connected clients.
591
592 An existing thread's model, mode, permission posture, workspace, and branch are
593 display-only in this client. Files/Changes, PTY/terminal, preview, artifacts,
594 provider login or global-default switching, Fleet creation, and
595 undo/retry/restore controls are intentionally absent until the Runtime publishes
596 explicit contracts for them.
597
598 ### Mobile control page
599
600 `codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a
601 phone-friendly control page at `/mobile`. It binds only to loopback
602 (`127.0.0.1` or `::1`); a non-loopback host is rejected because this Runtime
603 surface does not yet provide TLS or a verified overlay transport. The static
604 HTML page contains no Runtime bearer and is not itself token-gated. When
605 Runtime auth is enabled, the CLI prints a short-lived, single-use loopback
606 bootstrap URL. That capability creates a 30-minute, process-local
607 `Max-Age=1800; HttpOnly; SameSite=Strict` mobile session cookie plus origin-scoped browser
608 proofs. A sibling port that receives the host-scoped cookie cannot use it by
609 itself. The page can also exchange an explicitly entered bearer once, then
610 clears it rather than storing it in browser storage or a cookie. EventSource
611 connections use separate short-lived, single-use stream tickets.
612
613 The mobile page can list/create threads, send prompts, follow live SSE events,
614 steer or interrupt an active turn, and resolve normal tool approvals through
615 `POST /v1/approvals/{approval_id}`. It is a local-only convenience surface;
616 do not expose it directly to another device or public network until Runtime has
617 a TLS or verified transport boundary.
618
619 ### Endpoints
620
621 **Health**
622 - `GET /health`
623
624 **Sessions** (durable session manager)
625 - `GET /v1/sessions?limit=50&search=<fuzzy>&include_archived=false&archived_only=false&workspace=<path>&sort=recent|name|size`
626 - `GET /v1/sessions/summary?…` (same query params; projected row shape)
627 - `GET /v1/sessions/{id}` (add `?peek=true&entries=12` for a bounded, redacted
628 read-only peek instead of the full transcript)
629 - `PATCH /v1/sessions/{id}` (`{ "title"?: string, "archived"?: bool }`)
630 - `DELETE /v1/sessions/{id}`
631 - `POST /v1/sessions/{id}/resume-thread`
632 - `GET /v1/sessions/{id}/artifacts` and `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=`
633 (see workspace files and session artifacts above)
634
635 Sessions and threads answer the same `include_archived` / `archived_only` pair
636 with the same meaning, and `search` is the same fuzzy match (title, id,
637 workspace — substring, then subsequence) the TUI session picker and the workbar
638 Sessions list use. All three surfaces run one projection
639 (`crates/tui/src/session_projection.rs`), so a listing cannot differ between
640 the terminal and the dashboard.
641
642 `GET /v1/sessions/summary` returns rows that are field-compatible with
643 `GET /v1/threads/summary` — `id`, `title`, `preview`, `model`, `mode`,
644 `workspace`, `archived`, `updated_at` — plus `message_count`, `total_tokens`,
645 `created_at`, `parent_session_id`, and `is_current`. One caveat stated plainly:
646 `preview` is the session's recorded **title**, not its last message. Session
647 metadata does not store a last message, and reading every transcript to
648 synthesise one would make a list view an unbounded read. Full transcript
649 preview lives in the TUI session picker, which reads one selected session.
650
651 `PATCH /v1/sessions/{id}` renames and/or archives a saved session and returns a
652 lifecycle receipt shaped like the thread patch receipt:
653
654 ```json
655 {
656 "session": { "id": "…", "title": "Renamed", "archived": true, "…": "…" },
657 "changes": { "title": "Renamed", "archived": true }
658 }
659 ```
660
661 `changes` lists only what actually moved, so a no-op patch is distinguishable
662 from an applied one. Archiving is durable and reversible: an archived session
663 stays on disk and stays loadable, disappears from default listings, and is
664 never chosen by `--continue` or by auto-resume. The route is the same writer
665 the TUI picker (`e`) and `/sessions archive <id>` use — there is no second
666 archive notion.
667
668 While a session is open in an interactive Codewhale process, that process holds
669 the authoritative copy in memory and rewrites the whole document on its next
670 autosave. `PATCH` therefore fails closed on it with `409 Conflict` rather than
671 writing something that would be silently reverted. Change it in the terminal
672 instead. A standalone `codewhale web` holds nothing open and is never blocked.
673
674 `GET /v1/sessions/{id}?peek=true` returns a bounded, redacted, read-only view
675 instead of the transcript: at most 12 entries of at most 400 characters each
676 (`&entries=N` lowers the budget, never raises it past the cap), tool calls and
677 results summarised to a name and a size rather than inlined, and
678 credential-shaped substrings masked. `omitted_before` reports how many earlier
679 messages were dropped. The payload carries `"live": false` and deliberately has
680 no turn status, `running`, or `active` field — a saved session is a recording,
681 and live state comes only from a resumed thread's SSE stream.
682
683 **Threads** (durable runtime data model)
684 - `GET /v1/threads?limit=50&include_archived=false&archived_only=false`
685 - `GET /v1/threads/summary?limit=50&search=<optional>&include_archived=false&archived_only=false`
686 - `GET /v1/threads/running`
687 - `GET /v1/threads/{id}/notices`
688 - `DELETE /v1/threads/{id}/notices/{notice_id}`
689 - `POST /v1/threads`
690 - `GET /v1/threads/{id}`
691 - `PATCH /v1/threads/{id}` (see body shape below)
692 - `POST /v1/threads/{id}/resume`
693 - `POST /v1/threads/{id}/fork`
694
695 `POST /v1/threads` accepts optional execution defaults in addition to the
696 provider, model, workspace, and permission fields:
697
698 ```json
699 {
700 "model_provider": "openai-codex",
701 "model": "gpt-5.6",
702 "reasoning_effort": "high",
703 "allowed_tools": ["read_file", "search"]
704 }
705 ```
706
707 `reasoning_effort` uses the canonical Runtime vocabulary (`auto`, `off`,
708 `low`, `medium`, `high`, `xhigh`, `ultra`, or `max`; documented compatibility
709 aliases are accepted and persisted canonically). `allowed_tools` is a
710 model-visible allowlist. Omitting it keeps the normal configured catalog; an
711 explicit empty array (`"allowed_tools": []`) exposes no tools to the model.
712 Both fields are additive: older thread records and clients that omit them
713 retain their previous behavior.
714
715 `GET /v1/threads/summary` is the read-only summary surface used by the VS Code
716 Agent View. `search` matches thread `id`, `title`, and `model` (and, when the
717 title is unset, the latest turn's input summary — the displayed title). It
718 does not scan turn or item bodies: `preview` is filled only after a match, so
719 a dashboard keystroke is not a whole-store read per thread. Each item includes
720 `id`, `title`, `preview`, `model`, `mode`, `archived`, `updated_at`,
721 `latest_turn_id`, `latest_turn_status`, plus workspace metadata:
722
723 ```json
724 {
725 "id": "thread_...",
726 "title": "Implement MCP status count",
727 "preview": "The TUI footer should count project MCP servers...",
728 "model": "deepseek-v4-pro",
729 "mode": "agent",
730 "branch": "feature/runtime-api",
731 "head": "abc1234",
732 "dirty": false,
733 "workspace": "/Users/you/projects/codewhale",
734 "archived": false,
735 "updated_at": "2026-06-06T05:43:00Z",
736 "latest_turn_id": "turn_...",
737 "latest_turn_status": "completed"
738 }
739 ```
740
741 `branch` is resolved from the thread workspace at request time and may be
742 `null` when the workspace is not a Git repository or the branch cannot be read.
743 `head` is the current short Git commit for that workspace when available.
744 `dirty` is true when the workspace has staged, unstaged, or untracked changes.
745 `workspace` is included so editor clients can show when an agent lane is working
746 outside the current VS Code folder.
747
748 Thread forks are sibling runtime threads, not an in-place tree projection.
749 `thread.forked` events include `source_thread_id`; internal backtrack-aware
750 forks may also include `backtrack_depth_from_tail` and `dropped_turn_id`.
751 Thread list and summary responses remain flat in v0.8.40, so clients that need
752 a graph should reconstruct it from events instead of assuming list order is a
753 complete tree.
754
755 `GET /v1/threads/running` is the running-work accounting surface
756 (#6180): threads with at least one queued or in-progress turn, each with
757 `thread_id`, `model`, `title`, and `active_turns` (`turn_id` + `status`).
758 Background-capable clients use it for quit/background decisions — one call,
759 no inference from latest-turn status. Archive state is ignored (archiving
760 has no quiescence gate); an empty array means no owned work is live.
761
762 `GET /v1/threads/{id}/notices` is the per-thread active-notice surface
763 (#6180): the TUI-visible conditions a watch-only client must surface —
764 `subagent-terminal` (a child settled), `elevation-needed` (a tool call is
765 blocked on elevation), `model-notify` (the model asked the user to come
766 back) — each with `turn_id` and a `subject` id for targeting. Notices are
767 in-memory session state, bounded to 32 per thread (oldest evicted), and
768 never persisted. Clearing: elevation auto-clears when its tool call
769 completes; terminal/notify clear on `DELETE .../notices/{notice_id}`
770 (204, unknown ids 404). Unknown threads 404 on both endpoints.
771
772 `archived_only=true` returns archived threads only (mutually overrides
773 `include_archived`). Default behavior is unchanged: `include_archived=false`
774 and `archived_only=false` returns active threads. Added in v0.8.10 (#563).
775
776 `PATCH /v1/threads/{id}` body — every field is optional, missing means
777 "no change". At least one field must be present. `title` and `system_prompt`
778 accept an empty string to clear a previously-set value. Added in v0.8.10 (#562):
779
780 ```json
781 {
782 "archived": true,
783 "allow_shell": false,
784 "trust_mode": false,
785 "auto_approve": false,
786 "model": "deepseek-v4-pro",
787 "mode": "agent",
788 "title": "User-set thread title",
789 "system_prompt": "You are a useful assistant."
790 }
791 ```
792
793 **Turns** (within a thread)
794 - `POST /v1/threads/{id}/turns`
795 - `POST /v1/threads/{id}/turns/{turn_id}/steer` - inject guidance into the running turn. The response is a receipt for what actually happened, not for what was attempted; see [Steer delivery](#steer-delivery).
796 - `POST /v1/threads/{id}/turns/{turn_id}/interrupt`
797 - `POST /v1/threads/{id}/compact` (manual compaction)
798 - `POST /v1/threads/{id}/undo` - fork the thread with the last N turns removed (`{"depth": N}`, default 0 = last turn only); returns the forked thread plus `original_user_text` so a GUI can pre-populate the input box
799 - `POST /v1/threads/{id}/patch-undo` - snapshot-based whole-workspace rollback followed by the same fork (`{"depth": N}`); returns `patch_result` (`files_restored`, `summary`, `snapshot_label`) alongside the forked thread. See [Workspace restore endpoints](#workspace-restore-endpoints) for the trust, admission and abort rules.
800 - `POST /v1/threads/{id}/file-revert` - restore exactly one file from one named snapshot (`{"path", "snapshot_id", "expected_hash"}`); never forks the conversation. See [Workspace restore endpoints](#workspace-restore-endpoints).
801 - `POST /v1/threads/{id}/retry` - fork with the last N turns removed and immediately start a new turn (`{"depth": N, "prompt": "..."}`; `prompt` overrides the original user text, which is re-used when omitted)
802
803 `POST /v1/threads/{id}/turns` accepts the same optional
804 `reasoning_effort` and `allowed_tools` fields as per-turn overrides:
805
806 ```json
807 {
808 "prompt": "Review this change without running tools.",
809 "operation_key": "cwc-request-01J7Y6Q9W4",
810 "reasoning_effort": "max",
811 "allowed_tools": []
812 }
813 ```
814
815 Resolution is deterministic: a turn override wins over the thread default,
816 which wins over the Runtime's normal configuration. For tools, reaching normal
817 configuration means the ordinary configured catalog; `[]` is never treated as
818 missing. Reasoning is normalized only after the exact provider/model route is
819 resolved, and `auto` remains a per-prompt reasoning decision even when the
820 thread uses a fixed model. The request still enters the existing
821 `Op::SendMessage` path and the single `Engine::run_turn` loop.
822
823 Image input uses the same turn path: `"images": [{"mime": "image/png",
824 "dataBase64": "..."}]`. Clients must first observe
825 `capabilities.turn_image_inputs: true` in `/v1/runtime/info` (or the isolated
826 Runtime Chat relay catalog). Older HTTP runtimes ignore unknown fields, so a
827 successful text response is not evidence that an attachment was accepted.
828 The field is omitted when empty. It is also accepted by app-server
829 `thread/message`, `thread/request` messages, and prompt requests; that bridge
830 checks the underlying Runtime capability before forwarding image bytes.
831 Legacy remote Work commands do not support images and explicitly refuse them.
832
833 New inline images require a named model whose exact resolved route reports
834 `image_input: "supported"`; Auto and unknown/unsupported image routes are
835 refused before classifier or provider dispatch. This does not change the
836 existing trusted-local attachment behavior for routes with unknown capability.
837 A nonempty prompt is required. Inputs are limited to 10 images, 4 MiB decoded
838 bytes per image, 5 MiB total, and an 8 MiB JSON body. PNG, JPEG, GIF and WebP
839 must have matching MIME, canonical padded base64 and valid bounded image
840 content: at most 8192 pixels per dimension, 33,554,432 pixels total and 64 MiB
841 decoder allocation. The Runtime does not fetch paths or URLs from this field.
842 Malformed images refuse the whole turn; callers can retain the draft for
843 correction. Relay command polling uses an 8 MiB response budget; the sender
844 must paginate by serialized bytes without advancing past unserved commands.
845
846 Accepted image bytes and order are retained in the existing turn records and
847 reconstructed after restart, import and fork. Retry retains those images even
848 when its optional `prompt` changes the text; undo responses include
849 `original_user_images` when present. Image-bearing records require schema v3,
850 which older readers refuse. Text-only records and operation fingerprints retain
851 their prior representation. Validated stored local images retain the existing
852 5 MiB per-image ceiling and prior aggregate/count semantics on import/retry;
853 this internal storage authority does not
854 relax exact model or permission checks. Image bytes, MIME and order participate in request
855 identity, so changing an image under the same operation key conflicts.
856 Compaction can summarize older context; retaining the original attachment does
857 not promise that every later model request includes it. Image pixels are not
858 subject to text-secret redaction.
859
860 `operation_key` is an optional idempotency key for clients that may lose an
861 HTTP response after the Runtime accepted a turn. It is scoped to the current
862 Runtime store and thread, may contain at most 128 UTF-8 bytes, and may not be
863 empty or contain surrounding whitespace or control characters. Omitting it
864 preserves the legacy create-a-new-turn behavior.
865
866 The first accepted request durably binds a SHA-256 fingerprint of the key to
867 the Runtime turn id and a canonical request fingerprint before sending the
868 existing `Op::SendMessage`. An exact retry returns that original turn in the
869 normal `{ "thread": ..., "turn": ... }` response and emits no second engine
870 operation, item, or lifecycle sequence. Reusing the key on the same thread
871 with a different provider/model, prompt, reasoning policy, tool allowlist or
872 dynamic-tool schema, environment, or permission policy fails closed with
873 `409 Conflict`. The same caller key may be used independently on another
874 thread.
875
876 Only the scoped key fingerprint, request fingerprint, thread id, and turn id
877 are stored in the Runtime's private turn-operation index. The raw key is never
878 persisted or logged, and request bodies, credentials, and attachments are not
879 copied into that index. Existing thread/turn persistence remains the source of
880 the returned turn after a process restart.
881
882 **Exact accepted-turn lookup**
883
884 `GET /v1/threads/{id}/turn-operations/{operation_key}` uses the same Runtime
885 authentication as turn submission. URL-encode each path segment. It returns
886 `200 OK` with the existing bare `TurnRecord` (the `turn` object in the POST
887 response), identified by that exact thread and operation key. It does not use
888 the thread's latest turn or require the original request body or current route
889 settings to match.
890
891 - `404 Not Found`: no binding exists for that thread/key, or persisted identities
892 do not match. These cases share a generic response.
893 - `409 Conflict`: admission holds the operation claim, or its durable binding
894 is incomplete. Retry the lookup; this response does not authorize another turn.
895 - `400 Bad Request`: the thread ID or operation key is malformed. The key uses
896 the same 128-byte and whitespace/control-character rules as POST.
897 - `500 Internal Server Error`: storage or the existing claim lock cannot be
898 checked safely. This is not evidence that the operation is absent.
899
900 The lookup holds a shared read lock on the existing operation claim while
901 reading the binding and turn. It creates no files, starts no engine, emits no
902 events, and performs no replay or recovery. Normal Runtime startup may recover
903 an incomplete admission before a later lookup, but GET itself never does so.
904
905 **Approvals**
906 - `POST /v1/approvals/{approval_id}` with body
907 `{ "decision": "allow" | "deny", "remember": false }`
908
909 `approval_id` is minted by the Runtime, not by the model or the provider. It is
910 an opaque `approval_<32 hex>` capability, unique per prompt, bound to the thread
911 that raised it, and single-use: the Runtime removes it when the decision is
912 delivered, when the prompt times out, or when the turn abandons it. Clients echo
913 the value they were given and must not construct, derive, or guess one.
914
915 It is deliberately **not** the provider's tool-call ID. Providers restart their
916 call-ID counters per response, so two threads can gate calls whose raw IDs are
917 byte-equal; keying approvals by that value let one thread's decision settle
918 another thread's call. The endpoint therefore performs one exact match on the
919 minted ID and has no fallback: a raw tool-call ID, an expired ID, or a replayed
920 ID that has already been settled all return `404` and reach no engine. A `404`
921 means the capability is not pending — it is not evidence about how the approval
922 was resolved; read `approval.decided` for that.
923
924 The raw provider call ID travels separately as `tool_call_id` on
925 `pending_approvals[]` and on the approval events. It is a correlator for
926 attaching a prompt to the tool row it gates, and never accepted as a decision.
927 Each thread-detail `pending_approvals[]` entry is
928 `{ "id", "turn_id", "tool_name", "description", "intent_summary"?, "tool_call_id"? }`,
929 where `id` is the capability above.
930
931 **User input**
932 - `POST /v1/user-input/{thread_id}/{input_id}` with body
933 `{ "answers": [{ "id": "question-id", "label": "Choice", "value": "Choice" }] }`
934
935 Submitted values are delivered to the active model turn but are deliberately
936 excluded from durable Runtime items and events. The settled tool item contains
937 only a neutral receipt and a machine-readable `response_redacted` marker. The
938 Runtime accepts only an exact pending `(thread_id, input_id)` request; an
939 unknown, concurrently settling, or already settled id returns 404 and is never
940 placed in the engine mailbox. It commits the secret-free
941 `user_input.answered` receipt before removing the snapshot-authoritative prompt
942 or delivering the answer to the engine. That settlement runs independently of
943 the HTTP connection, so disconnecting after submission cannot leave a prompt
944 half accepted. Terminal-turn cancellation follows the same receipt-before-
945 removal ordering through `user_input.canceled`.
946
947 **Client-executed dynamic tools**
948 - `POST /v1/threads/{thread_id}/turns/{turn_id}/tool-calls/{call_id}/result`
949
950 The thread and turn in the result route must match the pending call. A call is
951 settled at most once; wrong-route and duplicate results return 404. Terminal
952 lifecycle events carry identifiers and status only, never tool result content.
953 The Runtime commits the terminal lifecycle event before making a submitted
954 result available to the model. Result delivery, timeout, and terminal-turn
955 cancellation race through one settlement owner, so exactly one of these events
956 is durable for a call:
957
958 - `tool_call.requested` — the typed client-executed call became pending;
959 - `tool_call.resolved` — a result was durably accepted by the Runtime
960 (`result_accepted: true`; `success` is result metadata, but result content is
961 excluded);
962 - `tool_call.timeout` — no result won before the bounded wait expired;
963 - `tool_call.canceled` — the turn terminated before a submitted result won.
964
965 HTTP `202 Accepted` and `tool_call.resolved` share that durable-acceptance
966 meaning. Neither claims that the model consumed the result: a concurrent turn
967 shutdown may close the model receiver after acceptance. Once the Runtime has
968 accepted the result, that call is terminal and a duplicate result returns 404.
969
970 **Events** (SSE replay + live stream)
971 - `GET /v1/threads/{id}/events?since_seq=<u64>`
972
973 Durable history parsing runs off the async server workers and reaches SSE in
974 bounded batches of at most 256 events through a backpressured channel. Broadcast
975 delivery is only a wake-up optimization: a lagged receiver opens the same
976 bounded durable replay from its last accepted cursor. Optional `replay_limit`
977 returns the newest requested tail and may not exceed 4096; `previous_seq` on
978 the first returned event advances past exactly the omitted history.
979
980 **Snapshots** (side-git restore point listing + restore)
981 - `GET /v1/snapshots?limit=20`
982 - `POST /v1/snapshots/{id}/restore`
983
984 `/v1/snapshots` lists recent side-git restore points for the runtime workspace.
985 `limit` defaults to `20` and must be between `1` and `100`. `POST
986 /v1/snapshots/{id}/restore` restores workspace files from the snapshot and
987 returns `{"restored": "<snapshot-id>"}`. It is the direct operator surface for
988 the server's own workspace (the same action as the TUI's `/restore <N>`): it is
989 gated by the Runtime API bearer token, not by any thread's trust flag, and it
990 is refused with `409` while a turn is active in an overlapping workspace (see
991 below). A `pre-restore:` safety snapshot is taken first.
992
993 ```json
994 [
995 {
996 "id": "snap_...",
997 "label": "post-turn:1",
998 "timestamp": 1780730580
999 }
1000 ]
1001 ```
1002
1003 ### Workspace restore endpoints
1004
1005 Three routes mutate workspace files from side-git snapshots. They share one
1006 admission rule and one safety net, and they differ in scope and trust.
1007
1008 | Route | Scope | Trust | Forks the thread |
1009 | --- | --- | --- | --- |
1010 | `POST /v1/snapshots/{id}/restore` | whole server workspace | bearer token only (operator action) | no |
1011 | `POST /v1/threads/{id}/patch-undo` | whole thread workspace | thread `trust_mode` or `auto_approve` when files would change | yes |
1012 | `POST /v1/threads/{id}/file-revert` | exactly one regular file | thread `trust_mode` or `auto_approve`, always | no |
1013
1014 **Admission.** A restore reserves the same admission the Runtime uses for
1015 config reloads and session checkpoints, so no new turn starts and no saved
1016 history changes while files are being rewritten. If any thread already has an
1017 active turn in the same workspace, a nested checkout of it, or a parent of it,
1018 the request is refused with `409` and the message `already has an active turn`.
1019 The reservation is owned by the worker performing the Git mutation, so a client
1020 that disconnects mid-request cannot release it early; the operation completes
1021 or fails as a whole. Concurrent restores serialize. The reservation is
1022 runtime-wide: while a restore's safety snapshot and checkout run, new turns,
1023 steering, compaction and user-input delivery on every thread wait for it to
1024 finish, so a large workspace can add seconds of latency elsewhere during a
1025 restore. A thread whose workspace directory is not available (unmounted
1026 volume, disconnected share, missing directory) is refused with `409` rather
1027 than treated as having nothing to restore.
1028
1029 **Safety net.** Every restore first records a `pre-restore:<target>` snapshot
1030 of the current workspace. That label is never a `/undo`, `patch-undo` or
1031 `file-revert` candidate, so the net does not change what later undos select.
1032 For `file-revert` the backup is mandatory: if it cannot be written, or the
1033 requested file is excluded from it (for example by `.gitignore`), the request
1034 fails and nothing is changed.
1035
1036 **`patch-undo`.** Selects the newest `tool:`/`pre-turn:` snapshot owned by the
1037 thread's own session whose tree differs from the workspace, restores the whole
1038 tree from it, then forks the conversation exactly as `/undo` does. `Ok` means
1039 either files were restored or there was provably nothing to restore (no bound
1040 session, or no differing session-owned snapshot); `files_restored` says which.
1041 When there is something to restore and the thread is not trusted, the whole
1042 undo aborts with `409` and neither files nor conversation change. Snapshot
1043 repository, listing or comparison failures abort with `500`, and an unavailable
1044 workspace directory aborts with `409`; both preserve the conversation, so a
1045 turn is never dropped while its file changes stay on disk.
1046 Depth and history are validated before any file changes. If the fork cannot be
1047 persisted after files were restored, the response is a `500` that names the
1048 restored snapshot; the original thread still holds the turn and the
1049 `pre-restore:` snapshot holds the previous files.
1050
1051 **`file-revert`.** Request body:
1052
1053 ```json
1054 {
1055 "path": "src/lib.rs",
1056 "snapshot_id": "3f2a…40-or-64 hex…",
1057 "expected_hash": "sha256:<64 lowercase hex digits>"
1058 }
1059 ```
1060
1061 - `path`: workspace-relative, or absolute inside the thread workspace. The
1062 name is literal (brackets, spaces and glob characters are filename bytes;
1063 Git runs with `--literal-pathspecs`). It must name a regular file: directories,
1064 symlinks anywhere in the path, and `.git` components are `400`.
1065 - `snapshot_id`: the exact `tool:<call_id>` or `pre-turn:<n>` snapshot from the
1066 change the user selected. Clients obtain ids from `GET /v1/snapshots` (labels
1067 carry the tool call id) and must keep the selected change's identity; the
1068 server never picks "the newest snapshot that differs", because an unrelated
1069 newer snapshot can erase later user edits while leaving the tool's change.
1070 - `expected_hash`: `sha256:` of the current file bytes the client displayed,
1071 or `absent` when the client saw the file as deleted. It is checked before the
1072 safety backup and again immediately before the mutation.
1073
1074 Responses:
1075
1076 - `200 {"path", "action", "snapshot_id", "snapshot_label"}` — `action` is
1077 `modified`, `recreated` (file was missing) or `removed` (the snapshot does
1078 not contain the file, so the file the tool created is deleted; its parent
1079 directories are left in place).
1080 - `400`: malformed `snapshot_id`/`expected_hash`, path outside the workspace,
1081 or a path that is not a regular file on either side.
1082 - `404`: unknown thread.
1083 - `409`: thread not in trusted mode or Full Access; no bound session; active
1084 turn in an overlapping workspace; workspace directory not available;
1085 snapshot unknown, owned by another session
1086 or not a restore point (refresh the change record); file already matches the
1087 snapshot (nothing to revert); or the file changed after the reviewed
1088 `expected_hash` (refresh and review again). Nothing is changed in any of
1089 these cases.
1090 - `422`: missing or mistyped body fields.
1091 - `500`: Git or filesystem failure; a failure after the safety snapshot names
1092 that snapshot so the previous bytes can be recovered with
1093 `POST /v1/snapshots/{id}/restore` or `/restore`.
1094
1095 Capability probe: `GET` on the route returns `405` where the endpoint exists
1096 and `404` on an older engine; clients treat any non-`404` as available and
1097 degrade with an explanation otherwise.
1098
1099 **Receipts** (future read-only audit export)
1100 - Proposed only: `GET /v1/threads/{thread_id}/turns/{turn_id}/receipt`
1101
1102 **Compatibility stream** (one-shot, backwards-compatible)
1103 - `POST /v1/stream`
1104
1105 **Tasks** (durable background work)
1106 - `GET /v1/tasks`
1107 - `POST /v1/tasks`
1108 - `GET /v1/tasks/{id}`
1109 - `POST /v1/tasks/{id}/cancel`
1110
1111 **Automations** (scheduled recurring work)
1112 - `GET /v1/automations`
1113 - `POST /v1/automations`
1114 - `GET /v1/automations/{id}`
1115 - `PATCH /v1/automations/{id}`
1116 - `DELETE /v1/automations/{id}`
1117 - `POST /v1/automations/{id}/run`
1118 - `POST /v1/automations/{id}/pause`
1119 - `POST /v1/automations/{id}/resume`
1120 - `GET /v1/automations/{id}/runs?limit=20`
1121
1122 Create and update requests accept an optional `model`. When present, each
1123 scheduled or manually triggered run uses that model; omitting it keeps the
1124 runtime's default task model.
1125
1126 **Operate** (always-on named operation; same `OperateRecord` as CWC
1127 `20de981` / PR #284)
1128
1129 - `GET /v1/operate` — current operation + plan board
1130 - `POST /v1/operate` — create (`direction`, optional `burnRate`)
1131 - `PATCH /v1/operate` — steer direction, `burnRate`, or `leadPlan`
1132 - `PUT /v1/operate/plan` — set `leadPlan` (`{ slices: [...] }`)
1133 - `POST /v1/operate/keepalive` — observe spend / burn; never stops
1134 - `POST /v1/operate/cancel` — explicit cancel (`/v1/operate/stop` aliases);
1135 also pauses the `cw-operate` keepalive so nothing keeps spending after cancel
1136 - `POST /v1/operate/auto-merge/check` — call landed
1137 `scripts/check-auto-merge.py --repo --pr --agent` (does not merge)
1138
1139 The operation record (`current.json`) is persisted under a cross-process
1140 file lock with atomic temp+rename writes; every PATCH / keepalive / plan
1141 save reloads the latest state inside the lock, so concurrent saves merge
1142 instead of losing writes. A `PATCH` that changes `direction` invalidates
1143 the recorded `leadPlan` (workers stop executing superseded slices) and
1144 pulls the keepalive lead run forward to re-plan. `POST /v1/operate`
1145 installs the hourly `cw-operate` keepalive and kicks its first lead-plan
1146 run immediately instead of waiting out the first recurrence; credentials
1147 resolve through the normal Z.ai provider resolution (config, `api_key_env`,
1148 secret store, or provider env vars — blank values count as missing).
1149
1150 `burnRate` is `{ "kind": "usd_per_hour", "amountUsdPerHour": number }`,
1151 a positive number, or `null` (unbounded). Status is
1152 `planning | running | idle_blocked | cancelled`. Pace
1153 (`unbounded | hold | throttle | widen`) is not a status: over-target
1154 throttles, under-target widens, no wallet-cap stop. Idle-blocked only
1155 for empty direction, awaiting lead plan, missing credentials, or a
1156 human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …
1157 --agent …` from codewhale-ops `origin/main` (exit 0), then
1158 `scripts/auto-merge-pr.py`. Do not invent a second checker.
1159
1160 **Introspection**
1161 - `GET /v1/workspace/status`
1162 - `GET /v1/workspace/files/search?query=<partial>&limit=<1-100>` (see workspace file suggestions above)
1163 - `GET /v1/workspace/files?path=<dir>&limit=<1-2000>`, `GET /v1/workspace/files/read?path=<file>&offset=&limit=`
1164 and `PUT /v1/workspace/files` (see workspace files and session artifacts above)
1165 - `GET /v1/skills`
1166 - `GET /v1/apps/mcp/servers`
1167 - `GET /v1/apps/mcp/tools?server=<optional>`
1168
1169 Skill activation toggles are persisted under a cross-process transaction lock.
1170 Each mutation reloads and merges the latest exact-name state before an atomic
1171 write, and `GET /v1/skills` refreshes that shared state so another Codewhale
1172 process's successful toggle is visible without restarting the Runtime API.
1173
1174 **Usage** (token/cost aggregation across threads)
1175 - `GET /v1/usage?since=<rfc3339>&until=<rfc3339>&group_by=<day|model|provider|thread>`
1176
1177 `since` / `until` are inclusive RFC 3339 timestamps and may be omitted (no
1178 bound). `group_by` defaults to `day`. Buckets are sorted by ascending key.
1179 Empty time ranges produce empty `buckets` (never a 404). Cost is computed via
1180 the model→pricing map; turns whose model has no pricing entry contribute
1181 tokens but `0.0` cost. Added in v0.8.10 (#564).
1182
1183 ```json
1184 {
1185 "since": "2026-04-01T00:00:00Z",
1186 "until": "2026-04-30T23:59:59Z",
1187 "group_by": "day",
1188 "totals": {
1189 "input_tokens": 12345,
1190 "output_tokens": 6789,
1191 "cached_tokens": 0,
1192 "reasoning_tokens": 0,
1193 "cost_usd": 0.012,
1194 "turns": 42
1195 },
1196 "buckets": [
1197 {
1198 "key": "2026-04-30",
1199 "input_tokens": 1234,
1200 "output_tokens": 678,
1201 "cached_tokens": 0,
1202 "reasoning_tokens": 0,
1203 "cost_usd": 0.001,
1204 "turns": 3
1205 }
1206 ]
1207 }
1208 ```
1209
1210 ### Native client routes (GPUI desktop)
1211
1212 These families serve the GPUI desktop client over the same bearer-token
1213 transport. They reuse the runtime's existing authorities — the engine's shell
1214 manager, the durable thread store, the workspace confinement layer, the
1215 config's credential plumbing — and add no second runtime, session store,
1216 scheduler, or credential store.
1217
1218 **Jobs** (operator-scoped shell jobs; the terminal surface)
1219 - `GET /v1/jobs` — every live and known-stale job across all threads
1220 - `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager:
1221 model-launched, subagent-launched, and client-launched together
1222 - `POST /v1/threads/{id}/jobs` — `{ "command", "cwd"?, "timeout_ms"?,
1223 "tty"?, "env"? }` → `201 { "job" }`; runs as a background shell under the
1224 thread's projected sandbox policy. `tty: true` merges stderr into stdout
1225 and gives the command a terminal (required for interactive programs);
1226 background jobs are never killed at `timeout_ms`
1227 - `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata
1228 - `GET /v1/threads/{id}/jobs/{job_id}/output?stream=<stdout|stderr>&cursor=
1229 <bytes>&max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=<base64|text>` — the
1230 resumable byte stream. `{job_id, stream, offset, next_cursor, total,
1231 dropped, encoding, data, status, exit_code, done}`: pass `next_cursor`
1232 back to continue; `wait_ms` long-polls for new bytes on a running job;
1233 `done` means a terminal status and nothing left past the cursor
1234 - `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ "data", "encoding"?,
1235 "close"? }`: `data` is UTF-8 text by default or `base64`, `close: true`
1236 sends EOF; works for PTY and piped jobs → `204`
1237 - `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL
1238 escalation on the process group → `{ "job", "result" }` with the final
1239 snapshot
1240
1241 Reads are non-consuming: several clients may hold independent cursors, and
1242 polling never steals output from the engine's own delta consumer. The
1243 buffer is bounded with exact drop accounting — a reader whose `cursor`
1244 falls behind the retained window gets `offset` past it and `dropped > 0`,
1245 and must re-anchor. Evicted jobs keep a tail snapshot which the output
1246 route serves as the final retained window. Jobs are scoped to the thread
1247 that created them and are killed when that thread is removed; the engine's
1248 background commands use the same per-thread manager, so `GET /v1/jobs` is
1249 also how a client sees model-spawned work.
1250
1251 **Commands** (typed command catalog, APPS-28)
1252 - `GET /v1/commands` — `{commands: [...]}`: every registered slash command,
1253 builtin and user, as the TUI's own registry holds it. Per entry: `name`,
1254 `aliases`, `summary` and `usage` (English source text — localizing is the
1255 client's surface), `subcommands` (the literal verbs the usage line
1256 declares), `takes_arguments`, `kind` (`builtin` registered code, or `user`
1257 expanding a stored template), `binding` (`host` runs locally and never
1258 reaches the model; `prompt` expands into the request the model sees),
1259 `discovery` (`primary` / `advanced` / `compatibility`, builtins only),
1260 `hidden` for rows the product does not advertise, and `shadowed_by` /
1261 `shadowed_aliases` where a user command has taken a builtin's spelling.
1262
1263 The same registry the TUI palette reads, so a desktop palette can be
1264 checked against it instead of drifting from it. Two rules a client must
1265 respect: a `binding: "host"` row is never submitted as a model prompt, and
1266 a user command shadowing a builtin name wins that spelling.
1267
1268 **Context** (per-thread context pressure, APPS-90)
1269 - `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live
1270 estimate the visible meter uses), `billed_input_tokens` (last
1271 provider-counted prompt size when one exists), `window_tokens`,
1272 `output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`,
1273 `compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the
1274 live engine via `Op::GetContextBudget`. Every numeric field is nullable —
1275 a route that cannot express a bounded window reports `null` rather than an
1276 invented number — and `live: false` marks responses where the engine
1277 could not be loaded and only the store-recorded route's static window
1278 resolved.
1279
1280 **Git** (workspace repository operations, APPS-106)
1281 - `GET /v1/git` — status detail: `git_repo`, `branch`, `head`,
1282 `ahead`/`behind`, counts, per-file porcelain `files[]`
1283 (`{path, index, worktree, staged, status, old_path?}`), `branches`,
1284 `remotes`
1285 - `GET /v1/changes` — the same porcelain `files[]` projection minus repo
1286 chrome (branches/remotes): one authority, so the change list can never
1287 disagree with the status read
1288 - `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`,
1289 or the empty tree on an unborn branch — which reads staged adds as new
1290 files). Covers staged+unstaged in one patch; `truncated` reports the
1291 512 KiB cap. An untracked file answers `untracked: true` with an empty
1292 diff — the client reads the file itself rather than mistaking it for
1293 unchanged
1294 - `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB,
1295 max 4 MiB) plus a complete `--numstat` `files[]` inventory
1296 (`{path, added, deleted}`) so every changed row renders even when the
1297 patch is truncated
1298 - `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`,
1299 `parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is
1300 an empty graph, not an error
1301 - `POST /v1/git/stage` `{ "paths": [...] }` or `{ "all": true }`;
1302 `POST /v1/git/unstage` same; `POST /v1/git/discard` `{ "paths": [...] }`
1303 (tracked paths only — no `all`, an untracked path fails closed);
1304 `POST /v1/git/commit` `{ "message", "all"? }`; `POST /v1/git/push`
1305 `{ "remote"?, "set_upstream"? }`; `POST /v1/git/branch`
1306 `{ "name", "create"? }`
1307
1308 Reads run through the hardened review command (filters, fsmonitor, hooks,
1309 lazy fetches and replace-objects neutralized); writes run through the
1310 non-interactive command path (`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a
1311 credential or host-key prompt can never hang a request. Path lists are
1312 workspace-relative under the same confinement as the file routes (traversal
1313 → 400, `.git` → 403), passed after `--` with literal pathspecs. Mutations
1314 answer `{ok, output, status}` with the refreshed status, so a client
1315 re-reads nothing after an operation. A workspace that is not a repository
1316 answers `404`.
1317
1318 **Diagnostics** (read-only logs, crashes, process — APPS-103)
1319 - `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` —
1320 the runtime's log directory plus `audit.log[.1]` from the codewhale home,
1321 newest first, capped
1322 - `GET /v1/logs/{name}?offset=<bytes>&limit=<bytes>&tail=<bytes>` →
1323 `{name, size, modified, offset, bytes, truncated, encoding, content}` —
1324 one bounded window; `tail` reads from the end and is mutually exclusive
1325 with `offset`; `truncated` means bytes remain after the returned window
1326 (a tail read at EOF is `false`), `encoding` is `utf-8` or `base64`
1327 - `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract
1328 over the crash-dump directories (`~/.codewhale/crashes`, legacy
1329 `~/.deepseek/crashes` merged)
1330 - `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds,
1331 executable, rss_bytes}` — `rss_bytes` only where the platform reports it
1332 (Linux `/proc`); absent rather than fabricated elsewhere
1333
1334 These routes package what already exists on disk for a client-side export;
1335 there is no telemetry upload route and no second log store. Names are
1336 basename-validated (no separators, no `..`), listings are capped, reads are
1337 bounded windows, and symlinks are never followed — a client bundles the
1338 files itself.
1339
1340 **Targets and remote posture** (APPS-50)
1341 - `GET /v1/targets` → `{targets: [self], remote: {supported: true,
1342 attach: "client", probe: "POST /v1/remote/connect"}, ssh: {…},
1343 cloud: {…}}` — this runtime's own record as the attachable target plus
1344 per-surface ownership; the runtime keeps no persistent target registry,
1345 so `POST /v1/targets` and `POST /v1/targets/switch` answer
1346 `501 Not Implemented` — target selection is client-owned and a switch
1347 must never move a running task server-side
1348 - `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan,
1349 auth_required, mobile, tls}` — this listener's reachability posture.
1350 `tls` is always `false`: the API has no TLS terminator, so non-loopback
1351 reachability assumes a verified overlay (VPN/mesh), never plain LAN trust
1352 - `POST /v1/remote/connect` `{ "endpoint": "http://host:port" }` — probes a
1353 candidate remote's unauthenticated `GET /v1/runtime/info` (origin only;
1354 any pasted path is discarded). Answers `{ok, remote: {endpoint,
1355 runtime_api_version, codewhale_version, auth_required, …}, attach:
1356 "client"}` on success, and `{ok: false, reason: "unreachable" |
1357 "not a Codewhale runtime" | …}` as data on failure. URLs carrying
1358 credentials are refused with 400 — the remote's token is configured
1359 client-side, and a connect route that forwarded one would be an
1360 exfiltration primitive
1361 - `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner:
1362 "codewhale-control-plane", reason}`; `POST /v1/ssh/connect` and
1363 `POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted
1364 cloud computers belong to the Apps control plane (ASCII Box for Managed
1365 Computer), not to a second authority inside Core
1366
1367 A remote Codewhale is a `serve --http` runtime with a token — that is the
1368 whole attach model. These routes describe and probe it; they never execute
1369 a remote request on the local machine.
1370
1371 **LSP** (workspace language intelligence, APPS-93)
1372 - `GET /v1/lsp` — capability: `enabled`, supported `languages` with their
1373 server commands, `custom_languages`, operations, poll and diagnostic caps
1374 - `GET /v1/diagnostics?path=` — file diagnostics
1375 - `GET /v1/definition?path=&line=&character=` (1-based)
1376 - `GET /v1/references?path=&line=&character=` (1-based)
1377 - `GET /v1/symbols?path=&query=` — empty query returns document symbols
1378
1379 One lazily-built workspace-level `LspManager` serves these; engine threads
1380 keep their own per-thread managers for the post-edit hook, and a server that
1381 never serves an LSP route never spawns a language server. `path` is
1382 workspace-relative under the same confinement as the file routes. Normal
1383 absence is data: no language server, a disabled `[lsp]` config, or a timeout
1384 answers `200` with `ok: false` and a machine-readable `reason`
1385 (`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a
1386 missing file a 404.
1387
1388 **Voice** (host dictation, APPS-98)
1389 - `GET /v1/voice` — capability: `available`, detected `recorder` command,
1390 resolved `asr` `{kind, model}`, `modes`, `send_phrases`,
1391 `max_record_seconds`
1392 - `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }`
1393 - `POST /v1/voice/send` — same capture with the "send it" / 发送/發送
1394 suffix contract: `send: true` tells the client to submit (empty `text`
1395 with `send: true` means submit the client's current draft)
1396 - `POST /v1/voice/control` `{ "composer": "draft text" }` — assisted
1397 dictation that shows the model the composer text; `assisted: false` in the
1398 response means a free ASR backend (local whisper/Groq) handled the audio
1399 and the composer context was never seen
1400
1401 The runtime owns the host microphone and the ASR dispatch — the same
1402 implementation the TUI's `/voice` commands run, headless. Recording is one
1403 blocking capture per host (requests serialize; the loser gets
1404 `ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its
1405 key lazily so local-whisper and Groq paths work without provider auth.
1406 Failure is data: `no_recorder`, `no_speech`, `no_provider_auth`,
1407 `transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator
1408 kill-switch — a headless `serve --http` host reports `available: false` and
1409 every dictate call fails closed.
1410
1411 ## Provider and model selection
1412
1413 These three routes are how a GUI renders a model picker whose contents are true
1414 for *this* runtime instead of guessed from a version snapshot. They were
1415 undocumented until 2026-08-04, which cost a desktop integration a day: the
1416 client probed `/v1/models`, `/v1/runtime/models`, and `/v1/runtime/providers`
1417 (all correctly 404) and concluded the capability did not exist.
1418
1419 ### `GET /v1/providers`
1420
1421 ```json
1422 {
1423 "current": "modelstudio-token-plan",
1424 "providers": [
1425 {
1426 "id": "modelstudio-token-plan",
1427 "model_provider_id": "modelstudio-token-plan",
1428 "display_name": "Alibaba Cloud Model Studio",
1429 "default_model": "qwen3.8-max",
1430 "has_model_catalog": true,
1431 "credentialState": "configured"
1432 }
1433 ]
1434 }
1435 ```
1436
1437 `current` is the active generic provider id. Only the active entry carries an
1438 exact identity: an active built-in normally repeats its canonical id in
1439 `model_provider_id`, while an active named custom route has `current` set to
1440 `custom` and the exact configured key there (for example `lm-studio`). Other
1441 entries have a null exact id; a null id on the active `custom` entry identifies
1442 the released legacy root-level custom route. Preserve both fields from the
1443 selected entry and send a non-null exact id back as `POST /v1/threads`'s
1444 `model_provider_id`; dropping a named custom id would collapse the selection to
1445 the legacy root custom route. `credentialState` is a stable, non-secret
1446 projection of the Runtime's existing structural credential classification:
1447
1448 - `configured`: credential material is structurally available;
1449 - `login_required`: the route needs login or a usable login capability;
1450 - `missing`: an API-style credential is unavailable;
1451 - `no_auth`: the route explicitly disables credential use;
1452 - `local`: the exact route is local and keyless;
1453 - `legacy`: the compatibility route cannot be classified more precisely.
1454
1455 For an active named custom provider, this state is calculated from the exact
1456 route named by `model_provider_id`, not from a generic custom-provider default.
1457 It deliberately collapses saved-key and imported-token details into
1458 `configured`, and login/consent-source details into `login_required`.
1459
1460 The response never includes endpoint URLs, credential environment-variable
1461 names, filesystem paths, credential values, consent-source details, or token
1462 metadata. `credentialState` is not a provider canary: `configured` does not
1463 prove endpoint reachability, credential validity, model entitlement, or a
1464 successful request. The models route below is also only a selection catalog; a
1465 non-empty list does not prove that the route can currently serve a request.
1466
1467 ### `GET /v1/providers/{id}/models`
1468
1469 ```json
1470 {
1471 "provider": "deepseek",
1472 "models": [
1473 {
1474 "id": "deepseek-v4-flash-vision-exp",
1475 "image_input": "supported",
1476 "reasoning_effort": "unknown",
1477 "reasoning_effort_levels": [],
1478 "reasoning_effort_source": null
1479 }
1480 ]
1481 }
1482 ```
1483
1484 For an exact configured route, supply `?model_provider_id=vision-work` and
1485 require the response to echo that same `model_provider_id`. The Runtime resolves
1486 that identity under the requested provider kind before reading model support.
1487 Unknown or mismatched identities return `400`. Named pagination cursors bind the
1488 configuration identity, endpoint and catalog snapshot; changing any of those
1489 requires restarting pagination. Omitting the query preserves the legacy catalog
1490 projection and omits the identity echo.
1491
1492 The catalog for one provider. Returns `400` for an unknown id, and for the
1493 legacy `deepseek-cn` alias, which has no provider metadata — use `deepseek`.
1494 An empty `models` array means the Runtime has no discoverable or configured
1495 model ids for that provider; it does not report credential presence.
1496
1497 The ids returned here are exactly the values accepted by `POST /v1/threads`'s
1498 `model` field and by the switch route below. `image_input` is the exact resolved
1499 provider/model route's capability state: `supported`, `unsupported`, or
1500 `unknown`. Keep `unknown` unknown rather than inferring from the model name or
1501 wire protocol. `supported` describes the model route; it does not mean a given
1502 client implements an image-upload control.
1503
1504 `reasoning_effort` uses the same three capability states and describes whether
1505 the exact model's metadata publishes a selectable effort ladder.
1506 `reasoning_effort_levels` contains only canonical, recognized active effort levels
1507 from that metadata. Off and provider synonyms such as none are excluded: the
1508 Apps/Chat protocol treats off as omission, which does not prove support for an
1509 explicit provider disable command. A model capable of reasoning may still have
1510 an unknown active effort ladder.
1511 Codex levels are also excluded when native compatibility would change their
1512 wire value (currently minimal and auto). This projection does not change native
1513 compatibility behavior or advertise a tier the Runtime cannot send unchanged.
1514 No levels are inferred from a provider-wide default or a familiar model name
1515 on a custom endpoint. `reasoning_effort_source` identifies `catalog`,
1516 `codex_cli_cache`, or `codex_app_server`; missing, stale, and unrecognized model
1517 metadata stays unknown. Codex roster metadata describes the external CLI's
1518 roster, not proof that a separately configured Runtime credential belongs to
1519 the same account or that an authentication boundary is approved.
1520
1521 Pass `?model_provider_id=<exact configured id>` when selecting a named route.
1522 The Runtime validates the provider kind and exact identity together, returns
1523 `model_provider_id` alongside that route's model list, and leaves the active
1524 configuration unchanged. An empty or unknown requested identity, or a mismatched kind, returns
1525 `400`; it never falls back to another named route.
1526
1527 The Runtime Chat relay publishes the same effort fields in camelCase
1528 (`reasoningEffort`, `reasoningEffortLevels`, `reasoningEffortSource`). These
1529 model facts do not enable tool execution or establish account entitlement.
1530
1531 For a thread-scoped choice, send the provider fields from the selected entry
1532 alongside the selected model. Omit `model_provider_id` when it is null:
1533
1534 ```json
1535 {
1536 "model_provider": "custom",
1537 "model_provider_id": "lm-studio",
1538 "model": "local-vision-model"
1539 }
1540 ```
1541
1542 This creates one thread on the exact named custom route without changing the
1543 Runtime's provider or model defaults.
1544
1545 ### `PUT /v1/providers/{id}/key` — write-only credential
1546
1547 ```json
1548 // request
1549 { "key": "sk-…" }
1550
1551 // response
1552 { "provider": "openai-codex", "stored": true, "backend": "keychain",
1553 "credentialState": "configured", "configPath": "/…/config.toml" }
1554 ```
1555
1556 Stores a provider API key through the same transactional write as
1557 `codewhale auth set --provider <id> --api-key-stdin`: the secret store under
1558 the provider write lock, plus the `[providers.<id>] auth_mode` metadata
1559 marker persisted to the config document and mirrored into the live runtime
1560 config so `GET /v1/providers` reports the new state immediately. `backend`
1561 names which secret backend holds the key and `configPath` which config
1562 document carries the marker (the user-global file when the ambient config
1563 is workspace-scoped).
1564
1565 The key is never returned — there is no read route for credential material,
1566 and neither the key nor its length appears in the response, errors, or
1567 logs; the response carries only the readiness projection
1568 (`credentialState`). An unknown provider id, the `deepseek-cn` legacy
1569 alias, an empty key, a key over 4 KiB, or one containing control characters
1570 is `400`. `credentialState: "local"` after a successful write is honest
1571 output for a keyless local route: the key is stored, but the route
1572 classifies as not needing one.
1573
1574 ### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential
1575
1576 ```json
1577 // response
1578 { "provider": "openai", "cleared": true, "credentialState": "missing" }
1579 ```
1580
1581 Clears the credential through the same shared owner as
1582 `codewhale auth clear`: the config document is snapshotted and restored if
1583 its save fails, the secret store is only touched once that save has landed,
1584 and the cleared markers are mirrored into the live runtime config so
1585 `GET /v1/providers` reports `missing` on the next read rather than after a
1586 restart.
1587
1588 Clearing an already-clear route returns `cleared: true` — a client retrying
1589 a revoke must not be told something went wrong. If the config entry is
1590 cleared but the secret backend refuses the delete, the route answers `500`
1591 and names the slot: reporting success while the key is still in the keyring
1592 would be a lie about a security action.
1593
1594 ### Credential ownership: `credentialSource` and `credentialWritable`
1595
1596 Both credential verbs refuse a route whose credential Codewhale does not
1597 own, and `GET /v1/providers` carries the same classification so a client can
1598 disable its control *before* submitting instead of failing late:
1599
1600 | `credentialSource` | `credentialWritable` | Meaning |
1601 | --- | --- | --- |
1602 | `secret_store` | `true` | Codewhale's own durable backend. The only writable source. |
1603 | `config` | `false` | A literal key in a config file, which still wins at request time. |
1604 | `external_auth` | `false` | An active external consent (OAuth) owns the credential. |
1605 | `none` | `false` | The route sends no credential, or has no credential slot. |
1606
1607 When `credentialWritable` is `false`, `credentialWritableReason` carries
1608 user-facing copy naming the owner, and both `PUT` and `DELETE` answer `409`
1609 with that same reason. The classification is structural: it reads declared
1610 auth mode, consent state and the *kind* of any configured `api_key` value,
1611 and never resolves a secret, an environment value, or an auth command. It is
1612 a class and never a value, a path, or an environment variable name.
1613
1614 ### `POST /v1/providers/{id}/switch`
1615
1616 ```json
1617 // request (model is optional; omit to take the provider default)
1618 { "model": "qwen3.8-max" }
1619
1620 // response
1621 { "provider": "modelstudio-token-plan", "model": "qwen3.8-max",
1622 "message": "…", "persisted": true }
1623 ```
1624
1625 **Use this rather than simulating a switch with repeated `POST /v1/config`
1626 writes plus a reload.** Provider and model move together here, the change is
1627 validated against the provider's catalog before it is applied, and `persisted`
1628 reports whether it was written to config or applied to the live session only.
1629 Rejects an unknown provider id and the `deepseek-cn` alias with `400`.
1630
1631 ## Runtime data model
1632
1633 The runtime uses a durable Thread/Turn/Item lifecycle.
1634
1635 - **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`,
1636 `model_provider` (generic kind), `model_provider_id` (optional exact configured
1637 route), `workspace`, `mode`, `task_id`, `system_prompt`, `latest_turn_id`,
1638 `latest_response_bookmark`, `archived`
1639 - **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed|
1640 failed|interrupted|canceled`), `effective_provider`, `effective_model`,
1641 `effective_billing_surface`, timestamps, duration, usage, error summary
1642 - **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message|
1643 tool_call|file_change|command_execution|context_compaction|status|error`),
1644 lifecycle `status`, `metadata`
1645
1646 Events are append-only with a global monotonic `seq` for replay/resume.
1647
1648 `effective_billing_surface` is a non-secret classification derived from the
1649 endpoint that served the turn. Recognized StepFun routes use `stepfun-payg` or
1650 `stepfun-plan`; unknown and custom endpoints leave it unset. The raw base URL is
1651 not persisted in `TurnRecord`.
1652
1653 ### Restart semantics
1654
1655 - If the process restarts while a turn or item is `queued` or `in_progress`,
1656 the recovered record is marked `interrupted` with an `"Interrupted by
1657 process restart"` error.
1658 - The trailing newline is an event append's commit marker. On startup, a final
1659 JSONL fragment without that delimiter is truncated and fsynced even when its
1660 bytes form valid JSON; it is an uncommitted append, and its already-reserved
1661 sequence number is not reused. Newline-terminated malformed records are not
1662 identifiable crash debris and continue to fail closed during replay.
1663 - If a terminal turn record reached disk but its terminal event sequence did
1664 not, the first async read reconciles any unresolved dynamic calls as
1665 `tool_call.canceled` and then emits one `turn.completed`. Existing terminal
1666 call and turn receipts are detected and never duplicated.
1667 - Turn-operation bindings survive restart. Retrying the same `operation_key`
1668 and request returns the original recovered turn (including an `interrupted`
1669 turn recovered from an in-progress process exit); mismatched reuse remains a
1670 conflict. A crash-created binding that never acquired a turn is discarded at
1671 startup because engine submission happens only after both records are
1672 durable.
1673 - Task execution performs its own recovery on top of the same persisted
1674 thread/turn store.
1675
1676 ### Approval model
1677
1678 - The `auto_approve` flag applies to the runtime approval bridge and engine
1679 tool context. When enabled for a thread/turn/task, approval-required tools
1680 are auto-approved in the non-interactive runtime path, shell safety checks
1681 run in auto-approved mode, and spawned sub-agents inherit that setting.
1682 - When omitted, `auto_approve` defaults to `false`.
1683 - [Authorization order](AUTHORIZATION_ORDER.md) describes where typed rules,
1684 registered tool requirements, safety floors, repository law, approval
1685 transport, and sandbox enforcement sit relative to one another.
1686
1687 ### SSE event stream
1688
1689 The SSE event payload shape for `/v1/threads/{id}/events`:
1690
1691 ```json
1692 {
1693 "schema_version": 1,
1694 "seq": 42,
1695 "previous_seq": 38,
1696 "event": "item.delta",
1697 "kind": "item.delta",
1698 "thread_id": "thr_1234abcd",
1699 "turn_id": "turn_5678efgh",
1700 "item_id": "item_90ab12cd",
1701 "timestamp": "2026-02-11T20:18:49.123Z",
1702 "created_at": "2026-02-11T20:18:49.123Z",
1703 "payload": {
1704 "delta": "partial output",
1705 "kind": "agent_message"
1706 }
1707 }
1708 ```
1709
1710 Compatibility notes:
1711
1712 - `schema_version` is the HTTP/SSE envelope schema version. It is independent of
1713 the runtime store schema used for persisted thread/turn/event records.
1714 - `event` remains the SSE event name in existing clients; it is preserved as-is.
1715 - `kind` mirrors `event` in the stable envelope for typed clients.
1716 - `seq` is allocated globally across all Runtime threads. Consequently, gaps
1717 between a thread's events are normal when other threads interleave. On this
1718 per-thread SSE stream, `previous_seq` is the sequence of the last event
1719 delivered for this thread (or the requested replay cursor for the first
1720 event); clients detect loss by comparing it with their accepted per-thread
1721 cursor, not by requiring `seq == previous_seq + 1`. Sequence allocation is
1722 also not rewound after an append is transactionally rolled back, so a retry
1723 can intentionally skip an unused value without implying a missing event.
1724 - `thread.started`, `turn.started`, and `turn.completed` are emitted as SSE event
1725 names exactly as before.
1726 - `timestamp` remains the canonical event time for schema version 1. `created_at`
1727 is an equivalent alias for clients that use `created_at` naming elsewhere; do
1728 not require both fields to be present.
1729
1730 ### Steer delivery
1731
1732 Putting a steer into the engine's mailbox is not the same as the model reading
1733 it. The engine discards a steer whose turn has already moved on, and an
1734 interrupted or failed turn drops whatever it had queued. The API reports the
1735 engine's real verdict rather than the attempt:
1736
1737 - The item is persisted `queued` when the steer is accepted into the mailbox.
1738 - **Delivered.** The engine committed the text into the turn's record: the item
1739 becomes `completed`, `steer_count` rises, and `turn.steered` + `item.completed`
1740 are emitted. `POST .../steer` returns `200` with that turn.
1741 - **Not delivered.** The turn moved on, was interrupted, or failed first: the
1742 item becomes `canceled`, `steer_count` does not rise, and `turn.steer_dropped`
1743 is emitted carrying `input`, `reason`, and the settled `item`. `POST .../steer`
1744 returns `409`, so a client can keep the user's text and resend it rather than
1745 clearing a composer over guidance that was never seen.
1746 - **Still pending.** A steer sent while the engine is inside a long tool call
1747 cannot settle until that call returns, and the request does not hang for it.
1748 After a short wait `POST .../steer` returns `200` with the item still `queued`;
1749 the eventual `turn.steered` or `turn.steer_dropped` event carries the verdict.
1750
1751 A client that treats `200` as "the model saw it" is therefore wrong in the third
1752 case: read the item's status, or wait for the event.
1753
1754 Common event names: `thread.started`, `thread.forked`, `turn.started`,
1755 `turn.lifecycle`, `turn.steered`, `turn.steer_dropped`, `turn.interrupt_requested`,
1756 `turn.completed`, `item.started`, `item.delta`, `item.completed`,
1757 `item.failed`, `item.interrupted`, `approval.required`, `approval.decided`,
1758 `approval.timeout`, `user_input.required`, `user_input.answered`,
1759 `user_input.canceled`, `tool_call.requested`, `tool_call.resolved`,
1760 `tool_call.timeout`, `tool_call.canceled`, `sandbox.denied`,
1761 `runtime.store_failure`.
1762
1763 `runtime.store_failure` is the runtime reporting a fault in the operator's own
1764 on-disk state: a thread, turn, or item record under the session's runtime
1765 store could not be read, parsed, or written. The payload carries `operation`
1766 (`read` | `parse` | `write`), `record_kind` (`thread` | `turn` | `item`),
1767 `record_id`, `path`, the full `error` chain, the root-cause `reason`, a
1768 `next_action` (which file to move aside, or where to check free space and
1769 permissions), and a one-line `message`. When `terminal` is `true`, the turn's
1770 own record is unreadable or unwritable and no `turn.completed` will follow;
1771 clients waiting on that turn should treat it as failed.
1772
1773 Agent-message and reasoning deltas are materialized into the item projection
1774 before their corresponding `item.delta` event is sequenced. To avoid an fsync
1775 for every provider fragment, adjacent deltas are coalesced to configured bounds
1776 of at most 32 ms or approximately 16 KiB before publication (an indivisible
1777 upstream chunk can itself exceed the byte target). A process crash inside that
1778 unpublished window can lose the recent suffix; no durable event claims that
1779 suffix existed. Once an `item.delta` is durable, snapshots at or beyond its
1780 cursor include the same materialized prefix.
1781
1782 `approval.required` events may include a `matched_rule` string when an
1783 execution-policy rule caused the prompt. This field is explanatory metadata for
1784 clients and does not grant or persist permissions.
1785
1786 `approval.required`, `approval.decided`, and `approval.timeout` carry two
1787 distinct identifiers. `approval_id` is the Runtime-minted, single-use capability
1788 described under **Approvals** — the only value `POST /v1/approvals/{id}` accepts
1789 — and `approval.required` also repeats it in the legacy `id` field for older
1790 clients. `tool_call_id` is the provider's raw tool-call ID, present for
1791 correlation only. Automatically resolved prompts (thread `auto_approve`, and the
1792 Auto-Review posture, which never opens a modal) mint an `approval_id` as well, so
1793 the field has one meaning on every path; those IDs register no waiter and are
1794 inert against the endpoint. Clients must never treat `tool_call_id` as an
1795 approval capability or assume it is unique across threads.
1796
1797 The thread event stream forwards these payloads intact. The compatibility turn
1798 stream carries `approval_id`, its `id` alias and `tool_call_id`; the pending
1799 snapshot carries the same capability and correlator so reconnecting clients can
1800 attach an approval prompt to its tool row.
1801
1802 ## Security boundary
1803
1804 - **Localhost by default**. The server binds to `127.0.0.1` by default.
1805 `--mobile` is also loopback-only and rejects a non-loopback host until a TLS
1806 or verified-overlay transport boundary exists. The runtime does not provide
1807 user isolation or TLS.
1808 - **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN`
1809 requires a matching bearer token for `/v1/*` routes. This is a local
1810 convenience guard, not a replacement for TLS, VPN, or a trusted reverse
1811 proxy on public networks.
1812 - **No provider-token custody**. The server never returns the API key. The
1813 `api_key.source` capability field reports `env`, `config`, or `missing` —
1814 never the key itself.
1815 - **No hosted relay**. The app-server is a local process under the user's
1816 control. There is no cloud component.
1817 - **Capability responses** never leak secrets, file contents, or session
1818 message bodies. They report *metadata*: presence, counts, status flags.
1819
1820 ### CORS allow-list
1821
1822 The runtime API ships with a built-in dev-origin allow-list:
1823 `http://localhost:3000`, `http://127.0.0.1:3000`, `http://localhost:1420`,
1824 `http://127.0.0.1:1420`, `tauri://localhost`. To add additional origins (e.g.
1825 when developing a UI on Vite's default `:5173`), use any of:
1826
1827 - CLI flag (repeatable): `codewhale serve --http --cors-origin http://localhost:5173`
1828 - Env var (comma-separated): `DEEPSEEK_CORS_ORIGINS="http://localhost:5173,http://localhost:8080"`
1829 - Config (`~/.codewhale/config.toml`):
1830 ```toml
1831 [runtime_api]
1832 cors_origins = ["http://localhost:5173"]
1833 ```
1834
1835 User-supplied origins **stack on top of** the built-in defaults; they do not
1836 replace them. Wildcard origins are not supported — the explicit allow-list
1837 model is preserved. Cross-origin preflights advertise only `Authorization`,
1838 `Content-Type`, `Accept`, `X-Codewhale-Runtime-Token`, and the compatibility
1839 `X-DeepSeek-Runtime-Token` request header; custom request headers are not
1840 allowed. Added in v0.8.10 (#561), tightened in v0.9.1 (#4454).
1841
1842 ## Managed Fleet Runtime and SDK helpers
1843
1844 The Runtime SDK lives in `npm/runtime-sdk` and is exposed as
1845 the `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every
1846 helper calls the local Rust Runtime API and therefore cannot bypass Codewhale's
1847 sandbox, approval prompts, provider configuration, or fleet ledger authority.
1848
1849 ```js
1850 import { createRuntimeClient } from "@codewhale/runtime-sdk";
1851
1852 const client = createRuntimeClient({
1853 baseUrl: "http://127.0.0.1:7878",
1854 token: process.env.CODEWHALE_RUNTIME_TOKEN,
1855 });
1856
1857 const created = await client.createFleetRun({
1858 target: "this_computer",
1859 roles: [{ name: "reviewer" }, { name: "verifier" }],
1860 workflow: {
1861 id: "release-check",
1862 kind: "parallel",
1863 tasks: [
1864 { id: "review", name: "Review", instructions: "Review locally.", worker: { role: "reviewer" } },
1865 { id: "verify", name: "Verify", instructions: "Verify locally.", worker: { role: "verifier" } },
1866 ],
1867 },
1868 });
1869
1870 // POST /runs only prepares durable work. This call crosses the launch gate.
1871 await client.startFleetRun(created.run.id);
1872
1873 let cursor;
1874 for await (const event of client.fleetEvents(created.run.id, { after: cursor })) {
1875 if (event.cursor) cursor = event.cursor;
1876 if (event.event === "fleet.replay.cursor_unavailable") {
1877 // Reload getFleetRun(created.run.id), then reconnect without the old cursor.
1878 }
1879 }
1880 ```
1881
1882 The managed path is deliberately two-step. `POST /v1/fleet/runs` validates and
1883 persists the run and queue without starting a worker. A separate authenticated
1884 `POST /start` activates it and schedules the executor driver; its `202` response
1885 reports `leased: 0` because the driver performs all leasing after it owns the
1886 run. Creation requires named roles, one task owner per role, a `parallel`
1887 Workflow, and an explicit Runtime target. v0.9.4 executes
1888 only `this_computer`; `another_computer` and `cloud` return `501` rather than
1889 silently executing locally. Worker IDs are generated per run; caller-assigned
1890 `worker_specs` return `501` until custom workers can be given collision-free
1891 managed identities. Parallel tasks with overlapping effective write roots are
1892 rejected before the run is journaled. Managed `security_policy` overrides also
1893 fail closed until that document can be enforced end to end; executable
1894 authority comes from each named role's tool posture and bounded task workspace
1895 scope.
1896
1897 Fleet helpers cover this HTTP surface:
1898
1899 | Helper | Runtime API route |
1900 |---|---|
1901 | `createFleetRun(spec)` | `POST /v1/fleet/runs` |
1902 | `startFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/start` |
1903 | `listFleetRuns()` | `GET /v1/fleet/runs` |
1904 | `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` |
1905 | `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` |
1906 | `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` |
1907 | `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` |
1908 | `stopWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/stop` |
1909 | `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` |
1910 | `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` |
1911 | `replayFleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events/replay` |
1912 | `fleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events` (SSE) |
1913
1914 `stopWorker` durably cancels that worker's active task and leaves the rest of
1915 the Fleet running. `interruptWorker` is the compatibility name for the same
1916 attempt-fenced cancellation transition. `stopFleetRun` cancels every queued or
1917 active task and marks the whole run cancelled.
1918
1919 Replay covers aggregate run/task transitions and privacy-bounded individual
1920 worker transitions. Event bodies omit prompts, tool call IDs, completion text,
1921 artifact paths/checksums, and cancellation identities; bounded failure reasons
1922 pass through secret redaction. `cursor` is opaque and stable across ordinary
1923 appends and Runtime restarts. Clients reconnect with `after=<cursor>`. A fresh
1924 request returns a bounded newest tail and marks `history_truncated` when older
1925 history exists. Ledger compaction can remove an old cursor; the JSON endpoint
1926 then returns `409`, while the SSE endpoint emits
1927 `fleet.replay.cursor_unavailable`, so the client reloads the current run
1928 projection instead of accepting a silent gap.
1929
1930 `GET /v1/runtime/info` advertises `fleet_run_create`, `fleet_run_start`,
1931 `fleet_event_replay`, `fleet_event_stream`, and `fleet_local_target`. Older
1932 runtimes without a requested route still produce a typed SDK
1933 `RuntimeCapabilityError`.
1934
1935 Verification:
1936
1937 ```bash
1938 npm test --workspace @codewhale/runtime-sdk
1939 ```
1940
1941 ## Agent Run Receipts
1942
1943 Sub-agent lanes persist compact run receipts in
1944 `.codewhale/state/subagents.v1.json`. The Runtime API exposes those receipts as
1945 a read-only inspection surface:
1946
1947 | Operation | Endpoint |
1948 |---|---|
1949 | List persisted agent runs | `GET /v1/agent-runs` |
1950 | Inspect one run | `GET /v1/agent-runs/{run_id}` |
1951
1952 The response is the same worker-record shape surfaced by `agent` receipts:
1953 `spec.run_id`, `actor_kind`, lifecycle `status`, bounded `events`,
1954 `follow_up`, `takeover`, `artifacts`, `usage`, and `verification`. `run_id`
1955 falls back to the worker id for older records, and `{run_id}` may be either the
1956 run id or the worker id.
1957
1958 These endpoints do not start, cancel, or steer sub-agents. The API surface
1959 exists so app/editor/headless clients can inspect the same handoff receipts that
1960 the TUI and parent model see.
1961
1962 ## Session lifecycle (native UI supervision)
1963
1964 | Operation | Endpoint |
1965 |---|---|
1966 | List sessions | `GET /v1/sessions` |
1967 | List session summaries | `GET /v1/sessions/summary` |
1968 | Get session | `GET /v1/sessions/{id}` |
1969 | Rename / archive session | `PATCH /v1/sessions/{id}` |
1970 | Delete session | `DELETE /v1/sessions/{id}` |
1971 | Resume into thread | `POST /v1/sessions/{id}/resume-thread` |
1972 | Create thread | `POST /v1/threads` |
1973 | List threads | `GET /v1/threads` |
1974 | Attach to events | `GET /v1/threads/{id}/events?since_seq=0` |
1975 | Send message | `POST /v1/threads/{id}/turns` |
1976 | Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` |
1977 | Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |
1978 | Compact | `POST /v1/threads/{id}/compact` |
1979
1980 ## Compatibility tests
1981
1982 Contract snapshots live in `crates/protocol/tests/`. Run:
1983
1984 ```bash
1985 cargo test -p codewhale-protocol --test parity_protocol --locked
1986 ```
1987
1988 This validates that the app-server's event schema hasn't drifted from the
1989 documented contract. CI runs this on every push to `main` and on release tags.
1990
1991 The app-server stdio control surface has its own drift guard — the advertised
1992 `capabilities` method set is pinned in `crates/app-server/src/lib.rs`:
1993
1994 ```bash
1995 cargo test -p codewhale-app-server capabilities
1996 ```
1997
1998 Before a release, run the headless smoke (stdio probe + optional provider
1999 matrix, no secrets leaked):
2000
2001 ```bash
2002 scripts/release/app-server-smoke.sh --matrix # dry-run plan
2003 bash scripts/release/app-server-smoke.test.sh # parser self-test (fake binary)
2004 ```
2005
2005 lines MARKDOWN