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