# Codewhale Engine Runtime API & Integration Contract > 阅读简体中文版:[zh_hans/RUNTIME_API.md](zh_hans/RUNTIME_API.md)。 `codewhale app-server --http` exposes the [Codewhale Engine](ARCHITECTURE.md) through its canonical local Runtime API and control plane. Local SDKs, mobile/remote-control clients, and editor integrations talk to it instead of screen-scraping terminal output. It serves the full HTTP/SSE runtime API (`/v1/*`), a JSON-RPC control transport over stdio, and the phone-friendly mobile page. `codewhale doctor --json` provides machine-readable health, and `codewhale serve --acp` speaks the Agent Client Protocol over stdio for editors such as Zed. `codewhale serve --http` / `serve --mobile` remain as **compatibility aliases** for `codewhale app-server --http` / `--mobile`; both launch the identical server. Desktop, web, SDK and custom integrations should target `app-server --http`. `--stdio` and `--socket` expose compatibility controls. Bare `app-server` selects initialized JSONL control over stdio. Local stdio, socket and ACP owners do not bind a hidden TCP listener. Explicit `--http` retains HTTP/SSE and compatibility routes on the same Runtime owner. `codewhale exec` is the separate one-shot headless worker path (stream-json, fleet worker subprocess, CI primitive). It is not part of this API, but it shares the same runtime, provider/model resolution, permission profiles, and event vocabulary. This document is the stable integration contract for native workbench applications (and other local supervisors) that embed the Codewhale Engine. ## Architecture ``` local supervisor / SDK / automation harness │ ├─ codewhale app-server --http → HTTP/SSE runtime API (/v1/*) [canonical] ├─ codewhale app-server --mobile → runtime API + mobile control page ├─ codewhale app-server --stdio → JSON-RPC control transport over stdio ├─ codewhale app-server --socket → same JSON-RPC over a unix domain socket (desktop daemon) ├─ codewhale doctor --json → machine-readable health & capability ├─ codewhale serve --acp → ACP stdio agent for editors such as Zed ├─ codewhale serve --mcp → MCP stdio server ├─ codewhale serve --http/--mobile → legacy aliases for `app-server --http/--mobile` └─ codewhale exec [args] → one-shot headless worker (stream-json) ``` The engine runs as a local-only process. All APIs bind to `localhost` by default. No hosted relay, no provider-token custody, no secret leakage. For the read-only record of what a thread or turn did, see [`docs/RECEIPTS.md`](RECEIPTS.md): `codewhale receipts` on the CLI and the `/receipt` routes under **Threads** below. ## Runtime API entrypoints | Entry | Transport | Use | |---|---|---| | `codewhale web [--port 7878]` | HTTP/SSE on `127.0.0.1:7878` + embedded client | First-class loopback-only browser client; opens the default browser | | `codewhale app-server --http` | HTTP/SSE on `127.0.0.1:7878` | Full `/v1/*` runtime API (canonical) | | `codewhale app-server --mobile` | HTTP/SSE on loopback + `/mobile` | Runtime API + local mobile control page | | `codewhale app-server --stdio` | Initialized JSONL over stdio | Compatibility controls and explicit tool-free turns through the held manager; no hidden TCP listener or full Codex interaction channel. | | `codewhale app-server --socket [--socket-path P]` | RPC text messages over WebSocket on a `0600` Unix domain socket (macOS/Linux) or an owner-only named pipe (Windows) | Desktop daemon: multi-client, kernel peer identity checked, `daemon/attach` claim handshake. Windows native-app qualification remains open. | | `codewhale app-server` | Initialized JSONL over stdio | Same selection as `--stdio`; HTTP requires explicit `--http`. | | `codewhale serve --http` / `--mobile` | same server as `app-server --http`/`--mobile` | Compatibility aliases | `app-server --http` and `--mobile` launch the same mature runtime API server historically reached through `serve --http` — no routes or behavior changed, so every endpoint documented below is identical across both entrypoints. The runtime API token is read from `--auth-token`, then `CODEWHALE_RUNTIME_TOKEN`, then `DEEPSEEK_RUNTIME_TOKEN`; use `--insecure-no-auth` only with a loopback bind. The `serve` compatibility aliases keep their `--insecure` flag. Bare `app-server` does not open an HTTP port. Selected attached HTTP listeners retain their explicit bearer and non-loopback security checks. Initialized stdio/private-socket controls support `thread/loaded/list` with `{cursor?: string | null, limit?: number | null}`. The result is `{data: string[], nextCursor: string | null}` from the held manager's loaded engine snapshot, including idle and archived engines still in memory. It does not enumerate saved history or only running turns. Omit the limit for the full snapshot. A zero limit is clamped to one entry when entries remain, so an empty page cannot retain an unchanged continuation. Pass the opaque `nextCursor` back unchanged; loading or unloading between pages can change the snapshot. The `app/thread_loaded_list`, `app/thread-loaded-list` and `app/request` `ThreadLoadedList` compatibility forms retain their thread-object wrapper and now use that same loaded-engine authority. Full-tool `turn/start`, interaction responses and subscription/reconnect parity remain unavailable. The pinned `thread/name/set` control takes `{threadId: string, name: string}` and returns `{}` after the existing held owner persists the canonical title. The legacy `thread/set_name`, `thread/set-name` and typed compatibility request retain their current parameter and response shapes. An empty or whitespace-only name clears the title through the same manager. The durable Runtime journal continues to publish `thread.updated`; the pinned `thread/name/updated` fan-out and per-connection subscriptions remain part of the unfinished event contract. Device tokens minted by the master through `POST /v1/auth/client-tokens` have an immutable `intent`: `watch` (the default when omitted) or explicit `drive`. Labels do not grant authority. Watch permits ordinary GET/HEAD reads, Computer display and one-use display tickets; it cannot mutate Runtime state, upgrade a protected HTTP read into a write channel, acquire/release control, or forward display input. Drive retains the existing Runtime/control authority but cannot mint, list or revoke device tokens. Display tickets retain the issuing principal's intent; input still requires its current, live control lease. `GET /v1/runtime/info` advertises `capabilities.client_token_intents: true`, and the mint receipt returns `device_id`, `intent` and `expires_at`. A relay grant issuer must require this capability before minting and validate that receipt against the requested device/intent before exposing a token. Older Engines lack enforcement and must refuse relay grants through this issuer; an intent-like label or a successful legacy mint is insufficient. This change does not add account/Computer ownership scopes to Engine-local device tokens. ### Workspace file suggestions `GET /v1/workspace/files/search?query=runtime&limit=20` returns `{"paths":["src/runtime.rs"]}` through the existing authenticated `/v1/*` router. It searches only the server's configured workspace, not a thread's workspace or the process's current directory. No workspace/path override is accepted. The response contains workspace-relative file paths with `/` separators, never file contents, absolute paths, or directories. - `query` is a literal partial filename/path, without an `@` prefix, at most 256 UTF-8 bytes. Missing, empty, or whitespace-only queries return an empty list without walking the filesystem. No match also returns an empty list. - `limit` defaults to 20; accepted values are 1–100. Invalid limits, oversized queries, and unknown query parameters return HTTP 400. - Matching reuses TUI fuzzy `@file` discovery/ranking: case-insensitive path prefix matches first, then substring matches, alphabetically within each group. This is not glob, subsequence, content, or semantic search, and does not apply the TUI's personal frecency boosts. - Discovery shares the composer's ignore policy, including `.ignore` and `.deepseekignore`, always-discoverable AI directories, and the bounded hidden/gitignored local-reference fallback. The special `.agents`, `.claude`, `.cursor`, and `.deepseek` walks intentionally bypass ignore rules, as in the TUI. Ignore files are not confidentiality boundaries. - Directory symlinks are not traversed. Files are canonicalized and filtered for containment in the workspace before applying the result limit; external and broken file symlinks are omitted. In-workspace file symlinks may appear by their relative names. Suggestions are a filesystem snapshot, not authorization to read a file later; consumers must revalidate when opening it. Discovery runs off the async executor, with the shared default depth of 10, at most 20,000 candidates, and a cooperative two-second discovery budget. Results are best-effort, not an exhaustive listing; a slow filesystem operation can finish after that budget. Each request scans anew; there is no new index or cache. This read-only endpoint does not alter sessions or the pinned model prompt/tool prefix. ### Workspace files and session artifacts Native clients browse and edit the server's configured workspace through three authenticated routes. They read and write the workspace directly; there is no second file store, cache or index, and no path override: the workspace root is the only root. - `GET /v1/workspace/files?path=
",
"diff": "@@ -0,0 +1,12 @@\n+{}\n", "diff_truncated": false
}
]
}
```
- This is the per-call counterpart of the turn aggregate, and the only
surface on which a **shell command's own writes** are attributable to that
command: a command produces no `metadata.mutation`, so nothing else names
what it wrote. The files a file tool changed are here too, read from the
same span.
- `change` is `created`, `updated` or `deleted` (git's `A`/`M`/`D`, with a
type change read as `updated`; the diff runs with `--no-renames`, so a move
is a delete plus a create). `added`/`removed` are `null` for a binary path.
- `size` and `revision` are the span's **end**, never the work tree as it is
now. `revision` is bare SHA-256 hex; pass `sha256:` followed by that hex as
`file-revert`'s `expected_hash`, or `absent` for a path deleted by the call.
Both are `null` when the path was deleted here or is too large to read.
- `diff` is the patch between the two restore points, cut at 64 KiB on a char
boundary (`diff_truncated` says so). It is `null` when there is nothing to
render: a binary path, a change with no content delta, or no patch.
- `state: "pending"` means the recorded call is queued or in progress and
its snapshot pair is incomplete. `reason` is `null` and `files` is empty;
read again after settlement. Once both receipts exist, the state is
`captured`, including when the call changed nothing.
- `state: "unavailable"` means the settled span cannot be resolved, and `reason`
says why: `call_not_bounded` (the call took no receipt — the engine judged
it read-only, or the turn predates receipts), `post_snapshot_missing` (the
opening receipt exists and the closing one was lost), `pre_snapshot_missing`,
or `snapshots_pruned` (the receipts are still on the turn, but the side repo
no longer holds the trees they name — snapshots are pruned to the newest few
while turn records are durable, so an older turn's span is regularly
unrecoverable, and a workspace whose store was deleted reads the same way).
This is **not** the same answer as an empty `files` list, which means the
span changed nothing, and the receipt's own `changed_paths` remain readable
on the turn record either way.
- `limit` (default 200, max 1000) caps the list; `truncated` says it was cut,
and how many paths were left off is not counted.
- Reading the span runs `git diff` inside the side repo. Neither the work
tree nor the index is touched.
| Status | When |
| --- | --- |
| 404 | Unknown thread or turn, a turn of another thread, or a `tool_call_id` this turn has no item or receipt for. |
| 400 | `limit` outside `1..=1000`. |
| 500 | A runtime item record could not be read or parsed, or an operational failure occurred reading the snapshot repository; this is not evidence that the call was unbounded or snapshots were pruned. |
The artifact routes answer:
| Status | When |
| --- | --- |
| 404 | Unknown thread, turn or artifact id, or a `revision` this turn never recorded. |
| 409 | A file's recorded revision is in neither the workspace nor the snapshot store (snapshots are pruned after 50 per workspace or 7 days), or a session artifact's bytes no longer hash to the recorded revision. The message names the current revision. |
| 410 | The turn deleted the file (restore it with `file-revert` and `restore_snapshot_id`), or a session artifact's bytes were pruned. |
| 413 | Content over 16 MiB. |
| 403 | A symlink, or a reference that leaves its root. |
Fleet receipt artifacts keep their own route
(`GET /v1/fleet/runs/{run_id}/receipts/{task_id}/evidence`).
### Runtime and account identity
`GET /v1/runtime/info` reports `codewhale_version` plus the full 40-character
`codewhale_commit` embedded by the shared CLI/TUI build. A source archive that
cannot provide an exact commit reports `unknown`, allowing compatibility
clients to fail closed rather than accepting an ambiguous binary pair.
`sandbox.kind` names the OS wrapper commands get (`linux-bwrap`, `macos-seatbelt`,
`none`); `sandbox.tool_launcher` is true when the managed configuration runs
tools as another user. Both are computed when the route is called, and with a
launcher `kind` is `linux-bwrap` only after the launcher probe has passed.
The same response advertises `capabilities.account_session: true` and
`capabilities.turn_operation_idempotency: true`. A client must require the
latter before relying on `operation_key`; do not infer support from a 2xx turn
response because an older tolerant reader may ignore an unknown request field.
`capabilities.turn_operation_lookup: true` separately advertises the read-only
operation lookup below; clients must require it before relying on GET-based
recovery of a lost turn response.
`capabilities.thread_create_idempotency: true` separately advertises
`POST /v1/threads` with `operation_key` (or `operationKey`). Keys use the same
validation as turn operation keys and are scoped to the Runtime store owner.
Exact retries return the original thread, including after response loss or
restart; a different effective request with the same key returns HTTP 409.
The fingerprint includes the resolved model/provider, workspace, reasoning,
permission and shell policy, tool allowlist, archival state, prompt/task fields,
dynamic tools and environments. Creation defaults are resolved before comparison.
Operation records retain only fingerprints and reserved identity, never the raw
key. Missing committed thread data is an error, never permission to create a
replacement. Clients must require this capability before relying on creation
replay, because an older reader may silently ignore an unknown request field.
The response also includes a token-free account receipt:
```json
{
"account": {
"schema_version": 1,
"state": "authenticated",
"api_base": "https://api.codewhale.net",
"account_id": "acct_...",
"session_id": "session_...",
"scopes": [],
"expires_at": "2026-08-01T20:00:00Z"
}
}
```
The Runtime reads this receipt from the exact profile- and API-origin-scoped
secure record written by `codewhale account login`; it does not run a second
login flow. States are `signed_out`, `authenticated`, `offline_cached`,
`expired`, or `revoked`. Scopes are copied only from explicit stored session
grants and are never inferred from account identity. Access/refresh tokens,
email, provider profile, and provider credentials are never returned.
`account_id` and `session_id` are included only for a request authorized with
the Runtime token (or an explicitly insecure loopback server); the public
bootstrap response remains usable but reports `signed_out`. Signed-out local
Work remains supported and never allocates cloud compute implicitly.
The native host account routes below require the configured master token in
the Authorization header. Browser/mobile device sessions and insecure mode do
not authorize them. Responses use `Cache-Control: private, no-store`. These
routes are excluded from the desktop renderer's Engine request allowlist.
| Route | Native host operation |
| --- | --- |
| `GET /v1/account/status` | Token-free account state, exact API origin/profile, opaque revision and whether legacy import is permitted. |
| `POST /v1/account/claim` | Renew a login claim only for the supplied `expectedRevision`, preserving existing account identity. |
| `DELETE /v1/account/claim` | Cancel exactly the supplied `expectedRevision`; a newer claim is preserved. |
| `POST /v1/account/import` | Adopt the host's completed browser authorization with `expectedRevision` and `auth`; legacy import requires pristine storage, while explicit sign-in supplies `replaceExisting`. |
| `POST /v1/account/lease` | Return a short-lived access credential to native main memory; Rust owns refresh and durable custody. Optional `rejectedAccessToken` requests renewal only if that credential is still current. No refresh token is returned. |
| `DELETE /v1/account/session` | Sign out exactly the supplied `expectedRevision`, preserving provider credentials and local work. A changed revision returns conflict rather than signing out another account. |
The lease is sensitive native-host transport, separate from the token-free
runtime receipt above. Native clients retain it only in main memory and fence
responses by Engine connection and account/session identity. Shared account
records use revision nonces and signed-out tombstones so cancelled or stale
login completion cannot restore an account after logout. Older authenticated
records remain readable; older binaries fail closed on signed-out tombstones.
The `--stdio` control transport uses newline-delimited RPC. Request IDs are
strings or signed 64-bit integers. Output omits `jsonrpc`; input also accepts
unknown input fields, as the pinned Codex decoder does. Complete `initialize`
once and receive its result. Clients then send the ID-absent `initialized`
notification before ordinary requests. It has no reply or authority effect;
as in the pinned implementation, server initialization is committed by the
successful initialize request. Repeated initialization and business requests
before initialize refuse. Malformed envelopes are discarded
without a fabricated null-ID response; invalid-ID method objects follow the
pinned notification fallback and cannot become business calls. Probe it
without spending model tokens:
```bash
printf '%s\n' \
'{"id":"init","method":"initialize","params":{"clientInfo":{"name":"probe","version":"1"}}}' \
'{"method":"initialized"}' \
'{"jsonrpc":"2.0","id":1,"method":"healthz"}' \
'{"jsonrpc":"2.0","id":2,"method":"capabilities"}' \
'{"jsonrpc":"2.0","id":3,"method":"shutdown"}' \
| codewhale app-server --stdio
```
Local control and ACP frames have an 8 MiB encoded body limit, excluding the
newline separator. Serialization stops at that limit before writing an
oversized frame. Body, newline and flush share a 30-second backpressure
deadline; owner attachment, request forwarding and frontend readiness keep
their tighter 5-second envelopes. A partial write, flush failure or deadline
ends the affected stream; it cannot be followed by another response or an
automatic replay. This write deadline also applies to compatibility stdio
replies/events and relay stdout. Compatibility execution, history, provider
catalogs, output-limit preflight and saved-config reload call the same held
native frontend directly; they do not make loopback HTTP requests.
`capabilities` returns the advertised method families (`thread/*`, `app/*`,
`prompt/*`) and the full method list; `thread/capabilities`,
`app/capabilities`, and `prompt/capabilities` scope it per family. The method
set is pinned by a drift test in `crates/app-server/src/tests.rs`, so SDK and
local integration clients can rely on it not changing silently. The permanently
refused compatibility goal setter is omitted from discovery while
`goal_execution` is false; goal reads and clears remain advertised.
### Daemon socket: `codewhale app-server --socket`
The socket transport exposes compatibility controls on a long-lived daemon.
The same dispatcher consumes RPC objects over private-channel WebSocket text
messages. Process stdio uses JSONL; the private channel upgrades before its
attachment handshake.
**Endpoint.** `--socket-path` if given; else `$CODEWHALE_HOME/run/daemon.sock`
when `CODEWHALE_HOME` is set (an explicit home is an isolation boundary); else
`$XDG_RUNTIME_DIR/codewhale/daemon.sock`; else
`~/Library/Application Support/codewhale/daemon.sock` on macOS or
`~/.codewhale/run/daemon.sock` elsewhere. On Unix, an implicit endpoint that
leaves insufficient room for the store-specific socket automatically uses a
private deterministic short temporary directory; the data home stays in place.
Explicit socket selections remain exact, and an overlong explicit path is
refused. The directory is created `0700`, the
socket is `0600`, and every accepted peer must present the daemon's own uid.
On start, a socket file nobody answers on is removed; a live one makes the new
daemon exit with `a live listener already answers on ; refusing to
replace it`; a non-socket file at the path is never touched. On Windows the owner-private named-pipe transport uses the same dispatcher;
its native application qualification remains open. There is no TCP fallback. The daemon prints
`codewhale daemon: listening on ` to stderr once it is accepting.
**Handshake.** After kernel peer admission, upgrade the private connection to
WebSocket. Upgrade headers are limited to 16 KiB and five seconds; any Origin
header refuses. Each subsequent text message contains one complete JSON object,
with an 8 MiB frame and reassembled-message limit. Binary messages are unsupported
and discarded without negotiation of compression. Control, scoped Control, ACP
and listener attachments all use this framing. Process stdio remains JSONL.
The first RPC request must be `daemon/attach` (`healthz` is also allowed beforehand). Every
other method is refused with `-32010 attach_required` until then. After the
private attachment handshake, complete `initialize` followed by `initialized`
on the Control connection before business requests. ACP retains its JSON-RPC 2.0
envelope, permission response IDs and EOF cancellation policy inside WebSocket
text messages. Named-pipe ACLs and peer-process checks remain mandatory.
```json
{"id":1,"method":"daemon/attach","params":{
"client":{"name":"codewhale-desktop","version":"1.2.3","pid":4242},
"mode":"claim",
"expect_daemon_version":"0.9.11"}}
```
`mode` is `"claim"` (this client spawned the daemon and manages its lifetime)
or `"attach"` (default: a guest that found a healthy daemon). A claim while
another connection owns the daemon fails with `-32011 daemon_already_claimed`
(`data.owner` names the holder) and the client should retry with `attach`.
`expect_daemon_version`, when present, must equal the daemon's crate version
or the attach fails with `-32013 daemon_version_skew` (the bundle-skew guard).
The reply reports the granted `role` (`owner` / `attached`), the daemon's
`pid`, `version`, `socket_path`, and `uptime_ms`, the current `owner`, and the
live `connections` count. A second `daemon/attach` on an attached connection
is `-32014 already_attached`.
**Capabilities.** On this transport `capabilities.methods` is the pinned stdio
set plus `daemon/attach` (second entry, after `healthz`); `transport` reads
`unix-socket` (or `named-pipe` on Windows), and `framing` is `websocket-text`.
Stdio advertises `framing: "jsonl"`. `shutdown` is advertised to every connection because the method
exists, but only the owner may call it (below).
**Ownership.** Only the owner may `shutdown`; a guest's `shutdown` is refused
with `-32012 not_daemon_owner` and does not interrupt anyone's turn. When the
owner disconnects the slot frees, so a relaunched shell re-claims the daemon it
left running. The owner's `shutdown` stops the listener, closes every
connection, and removes the socket file. Journal replay from a client's
last-seen `seq` is not part of this transport yet.
### Compatibility execution limits
The compatibility HTTP, stdio and socket interfaces advertise
`interactive_execution: false`, `goal_execution: false`, `tool_free_turns: true` and
`full_api_entrypoint: "codewhale app-server --http"`. Clients must require
`tool_free_turns: true` before relying on an empty allowlist: older compatibility
servers may ignore the field. Every `thread/message`,
`thread/request` message, `prompt/run`, `prompt/request` and HTTP `/prompt` or
`/thread` message requires an explicit `allowed_tools: []`. Omitted, null or
nonempty lists fail with `interactive_execution_unsupported` before creating a
Runtime thread or starting a turn. Malformed lists fail parameter decoding.
JSON-RPC reports `-32602` with `data.error: "interactive_execution_unsupported"`;
HTTP reports 400 with `error.code: "invalid_request"`, `error.jsonrpc_code: -32602`
and the same detail in `error.data`. Compatibility `thread/goal/set` and
`ThreadRequest::GoalSet` also refuse before changing the goal: setting an active
goal starts background Engine work. Reading and clearing existing goals remain
available; starting goals uses the full Runtime HTTP API. The known setter
remains recognized for older clients to get this actionable refusal, but it is
not advertised as an available method. For example:
```json
{"jsonrpc":"2.0","id":3,"method":"thread/message","params":{"thread_id":"thr_...","input":"Summarize this conversation","allowed_tools":[]}}
```
The bridge sends the same empty allowlist to the canonical Runtime for each
admitted turn, so a tool-enabled thread cannot widen it. It forwards assistant
text and terminal status. Unexpected approval or clarification requests fail
and interrupt the exact owned turn; they cannot wait silently. This deliberately
narrows the old compatibility behavior. Tool-enabled clients must migrate to
the authenticated `/v1/*` API and SSE rather than enabling Full Access.
### Interrupting a turn
`thread/message` streams until the turn reaches a terminal state, which can
take minutes. The read loop keeps polling stdin while a turn streams, so a
client can send:
```json
{"jsonrpc":"2.0","id":9,"method":"thread/interrupt","params":{"thread_id":"thr_..."}}
```
and the runtime is asked to interrupt that turn
(`POST /v1/threads/{id}/turns/{turn_id}/interrupt`). The reply carries
`interrupted: false` when no turn is streaming for that thread — this is not
an error, just nothing to stop. The interrupted `thread/message` then fails
with a `turn interrupted` error, and its reply is written before the
interrupt's own reply, since the turn owns the writer until it unwinds.
`shutdown` sent during a live turn also interrupts first: it needs the same
bridge that the turn holds, so without that it would wait for the very turn
it was meant to stop. Other requests that arrive mid-turn are queued and run
in order once the turn finishes.
### Running a prompt
`prompt/request` and `prompt/run` (byte-identical aliases) and the legacy
HTTP `POST /prompt` all execute a **real turn** on the runtime, through the
same bridge `thread/message` uses. There is no local fallback: nothing else
in the app-server can produce model output, so a prompt either runs or fails.
- `params.prompt` is required and must be non-empty (`-32602` otherwise).
- `params.allowed_tools` must be `[]`; interactive turns use `app-server --http`.
- `params.thread_id` is optional. With one, the prompt runs on that thread and
its history. Without one, the runtime gets a fresh thread for that single
turn; the mapping is dropped when the turn ends, so a one-shot prompt is not
addressable by `thread/interrupt`. Use `thread/message` when you need to be
able to interrupt.
- `params.model` selects the model only when the call is the one that creates
the runtime thread; an existing thread keeps the model it was created with.
- The response carries what the model actually said: `output` is the
concatenated `agent_message` text, `model` is the model the runtime reports
for the thread that ran it, and `events` are the real
`response_start`/`response_delta`/`response_end` frames. Over stdio the same
frames are also streamed to stdout while the turn runs, exactly as for
`thread/message`.
- If the runtime cannot be reached, the call fails with `-32005`
(`runtime_unavailable`) on stdio, or HTTP `503` with
`{"error":{"code":"runtime_unavailable", ...}}` on `POST /prompt`. Failures
are never shaped like a successful `PromptResponse`.
`POST /thread` with a `Message` body behaves the same way — it runs the turn
and replies `status: "completed"` with the streamed frames in `events` — where
it previously replied `accepted` without doing anything.
### Compatibility list and read parameters
`thread/list` accepts only `include_archived` and `limit`; `thread/read`
accepts only `thread_id` and returns the complete legacy history projection.
Both also apply inside `thread/request`. Unknown fields fail with `-32602`
before contacting the canonical owner. Workspace, cursor, `archived`, search
and `includeTurns` parameters from the pinned v2 contract are not implemented
by these compatibility methods; they are rejected instead of silently ignored.
These method names do not establish Codex wire parity.
### Thread ids and restarts
`thread/message`, `thread/request` messages, and HTTP `POST /thread` messages
take a thread id from `thread/create` (or `thread/fork`). An id that was never
created fails with `-32004` (`thread_not_found`) on stdio, or HTTP `404` on
`/thread`, before any runtime thread is started. `/prompt`, `prompt/request`,
and `prompt/run` are different: their optional `thread_id` is any key the
caller chooses, and a new key starts a new conversation.
The authenticated canonical owner keeps the full saved conversation graph,
its selected branch and the thread/session binding. Compatibility controls use
that owner; the old SQLite history remains a protected, read-only import source.
Import compares the complete source graph and selected leaf before publishing
the canonical alias. A failed alias publication retains the source and the
actual canonical result so recovery can report what completed.
Existing validated legacy goals are imported into the owner goal store. Active
goals are paused during import; historical data never starts a provider call.
Source goal fields participate in the same protected source comparison.
Saved-session forks preserve the complete journal, including inactive branches,
and copy a validated local session-goal sidecar into the new saved session.
An active local goal is copied as paused; the source remains unchanged.
That sidecar is separate from the public Runtime thread goal. A native Runtime
thread fork does not automatically inherit the public thread goal.
`thread/create`, `thread/start`, `thread/resume` and `thread/fork` carry a
client-generated `operation_key`. Capture one key for each user intent before
sending it, retain it after an uncertain response, and reuse it when recovering
that same intent. Create carries the key in `metadata.operation_key`; Start,
Resume and Fork carry it in `operation_key`. Two intentional forks use different
keys. Recovery checks
the original operation in the existing owner store; it never creates another
thread merely because the response was lost or the source later grew.
`POST /v1/thread-history/operations/lookup` is read-only. Its closed request
contains `version: 1`, `operation_key`, `expected_data_dir`,
`expected_execution_scope` and `workspace`; the response is `absent`, `pending`
or `committed`. A pending or committed response includes the retained receipt
and its exact action/source `association`.
`POST /v1/thread-history/operations/recover` accepts that lookup request in
`operation` and the expected `association`. It explicitly finishes an already
prepared target under the same owner, after checking the saved document, full
graph, workspace, checkpoint and action/source identity. It does not rebuild
the original intent from a source that may already have changed. An unprepared
target stays pending; a changed or unverifiable target refuses completion.
Repeat recovery with the same key to observe the same committed result.
New history reservations retain their original document/checkpoint observation.
An uncommitted recovery must match the complete prepared document and the
operation's own publication generation, with its original checkpoint unchanged.
An older reservation without that original observation stays pending or refuses
recovery; reading current records cannot retrospectively supply it.
The selected workspace comes from the acknowledged owner or the explicitly
admitted request. History and historical receipts cannot supply permissions,
credentials, endpoints or a different owner. A missing, changed, busy or
incompatible bound store is an explicit failure. A missing canonical target
does not start an empty replacement conversation.
`codewhale thread resume` and `codewhale thread fork` perform the durable
owner control and print its committed thread, session and operation receipt.
They do not launch an interactive frontend.
Global `--workspace` (also `--cd`), `--profile` and `--config` select an explicit
control scope. Relative paths are captured before attachment; the client
authenticates the owner, then admits that scope against the same owner receipt
and its captured worker setting. An incompatible profile or config, missing
scope facts, or a changed owner fails explicitly. Without these options, the
acknowledged owner supplies the workspace. Thread listing remains store-wide.
For a fresh `thread resume` or `thread fork`, global `--provider`, `--model`,
`--approval-policy` and `--sandbox-mode` supply proposals to the existing owner
decoder and permission checks. The corresponding `--set` keys are `provider`,
`model`, `default_text_model`, `approval_policy` and `sandbox_mode`. Credentials
and endpoints stay with the owner: `--api-key`, `--base-url` and other per-run
settings are refused for these controls. Configure and authenticate the owning
Runtime before using them. With a retained `--operation-key`, newly supplied
model, provider, policy or sandbox proposals are refused; recovery observes
the original admitted intent.
Interactive `codewhale resume` and `codewhale fork` use the same canonical
history operation. An inactive local owner is shut down and joined before the
existing TUI acquires the saved-session lease and store. A live owner or an
uncertain handoff refuses attachment. `--operation-key ` recovers the
original outcome, including completing a verified prepared target; an
unprepared or unverifiable result preserves uncertainty.
### Changing config
`app/config/set` and `app/config/unset` write the change to the config file
before replying: the `--config` path, or the default `config.toml` used by the
canonical owner when no `--config` is given. They change user settings that
outlive the app-server, not just this session. The change is applied to the
file as it is on disk, so edits saved by other processes are kept. If the file
cannot be read, parsed, or written, the reply is `ok: false` and nothing
changes; a file that does not parse is not rewritten, so fix it by hand (or
`app/config/reload` after fixing it). Over HTTP `/app`, a rejected key or value
is `400` and a read or write failure is `500`.
### Answering a clarification question
When a headless turn calls `request_user_input`, the runtime emits a
`user_input.required` event carrying a `request_id`. Reply on the runtime API:
```
POST /v1/user-input/{thread_id}/{request_id}
```
The compatibility control transport cannot accept that reply.
`app/request` with `SubmitUserInput` returns `ok: false` and
`error: "user_input_reply_unsupported"`. Compatibility turns require an empty
tool catalog and refuse interactive execution at admission, so this limitation
cannot leave a supported turn waiting for an answer. The fixed catalog and
response limitation are Codewhale implementation choices.
## SDK contract
The `@codewhale/runtime-sdk` package currently supplies Fleet helpers and
read-only thread event streaming. It does not wrap thread creation, turn
start/steer/interrupt, approval decisions, clarification answers or model
discovery. Use authenticated raw HTTP for these operations; the package guide
provides the [request recipe](../npm/runtime-sdk/README.md#full-engine-api).
The hosted Responses SDK is a separate contract.
The full `app-server --http` API exists so an external SDK can answer — without scraping TUI
output — *what route ran, which provider/model/reasoning/permission profile was
effective, what events happened, how many tokens were used, and how the run
finished.* The durable Thread/Turn/Item data model already carries most of
this; the table maps each integration need to where a local client reads it.
| Integration need | Where it comes from | Status |
|---|---|---|
| Route / effective model / billing surface | `TurnRecord` + thread `model`; per-run `--provider`/`--model` overrides | available |
| 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 |
| Run / thread / turn IDs | `thread_id`, `turn_id`, SSE event envelope | available |
| Event stream | `GET /v1/threads/{id}/events` (replay + live SSE) | available |
| Turn status / terminal classification | `TurnRecord.status` + error summary | available |
| Token usage | `TurnRecord.usage`; aggregate via `GET /v1/usage` | available |
| Action receipt (files, commands, web/MCP calls, agents, approvals and who decided, failures) | `GET /v1/threads/{id}/receipt`, `GET /v1/threads/{id}/turns/{turn_id}/receipt` | available ([RECEIPTS.md](RECEIPTS.md)) |
For one-shot/headless automation, prefer `codewhale exec` with explicit
`--provider --model ` so a failure identifies the exact provider/model
pair. Use `app-server --http` when a local integration needs to start, resume, steer,
or interrupt turns, list models/capabilities, follow the event stream, or read
usage. Both paths share the same runtime, so route-effective model resolution
and the event vocabulary match.
### Release smoke
`scripts/release/app-server-smoke.sh` is the committed pre-release check:
```bash
scripts/release/app-server-smoke.sh # stdio health/capabilities probe (no tokens)
scripts/release/app-server-smoke.sh --matrix # + print the configured provider/model matrix
scripts/release/app-server-smoke.sh --matrix --real # + exec a cheap sentinel per provider
```
The stdio probe runs against a throwaway config, so it never reads real keys.
The matrix discovers configured providers from `codewhale auth list`, skips
unconfigured providers, and maps a provider to a cheap sentinel model only when
it has a built-in cheap default. That built-in set is deliberately conservative
(currently `deepseek`, `zai`, `moonshot`, and `openai`); every other provider —
including `arcee`, `openrouter`, `xiaomi-mimo`, and `openai-codex` — is left
unmapped on purpose and must be given a model per run via `SMOKE_MODEL_`
rather than a guessed default (#3205). Any configured-but-unmapped provider
fails loudly in `--real` mode. `auth list` reports presence flags only and exec
output is passed through a redactor, so secrets are never printed. The parser is
covered by `scripts/release/app-server-smoke.test.sh` against a fake `codewhale`
binary.
## ACP stdio adapter: `codewhale serve --acp`
ACP JSON-RPC over newline-delimited stdio is a transport over the existing
RuntimeThreadManager and Engine. It has no provider/tool round loop, executable
registry, prompt composer, or conversation writer of its own. The server loads
the selected config/profile and plugin discovery once; each prompt uses a
canonical thread, Core turn, event timeline, approval waiter, and full Engine
session snapshot.
The editor surface supports `initialize`, `session/new`, `session/list`,
`session/load` (including durable ID prefixes), `session/prompt`, `session/cancel`,
model discovery/selection, and the advertised mode/model config options.
`session/new` creates a durable bare UUID and empty checkpoint without calling a
provider. `session/load` replays the saved conversation as `session/update`
notifications before it responds: user and agent text, then each tool call with
its final status. Replayed tool results are masked as on the live stream: a
`request_user_input` answer becomes `User input submitted` and configured
credentials are redacted. Reasoning and images are not replayed. At most 64 idle transport bindings are retained; eviction keeps the
durable conversation. Resume deduplicates against the existing thread binding.
Full histories, tool pairs, signatures, media and partial-effect receipts are
saved under the same checkpoint guard and session write lease used by HTTP.
ACP display text may be shortened; the saved history is the full Core snapshot.
Codewhale 0.10.2 implements a partial ACP v1 adapter. Both `session/new`
and `session/load` reject a nonempty client-supplied `mcpServers` list with
`-32602` before creating, resuming or returning a cached session. The field may
be omitted or `[]`; null and other non-array values are invalid. No supplied
command is executed during this validation. [ACP v1 requires stdio MCP support](https://agentclientprotocol.com/protocol/v1/session-setup#mcp-servers);
`mcpCapabilities.http: false` and `sse: false` only describe optional transports
and do not establish full conformance. Use the full Runtime API and its existing
MCP management for those integrations. The restricted tool catalog below is a
Codewhale host profile, not an ACP protocol restriction.
The trusted local ACP profile narrows Core to file/search/git/patch and admitted
foreground shell tools, plus reviewed plugin tools (`extension_host` feature) and
the tools of MCP servers configured in Codewhale (`mcp` feature), under the same
permission setting. Shell requires both editor terminal support and operator
`allow_shell`; a requested external sandbox that is unavailable removes shell.
Client-supplied MCP servers, dynamic tools, task/PTY/background shell,
interpreter, subagent and RLM lifecycles are unavailable on this transport.
Built-in overrides remove the
whole compatibility alias family. Final dispatch rechecks the profile, so a
fabricated alias, hook rewrite or omitted catalog entry cannot bypass it. Plan
remains read-only under Full Access. Full Access and the ordinary approval
posture are server-owned read-only options; the editor cannot widen them.
`approval_policy = "never"` is not an ACP posture, so the server refuses to
start with it instead of serving it as Ask.
Full Access is `yolo`. Start it with `codewhale serve --acp --full-access`
(the flag may also come before `serve`; `--yolo` is an alias) or set
`yolo = true` in config. A configured `sandbox_mode = "danger-full-access"` does
not turn it on: that value can only narrow the sandbox the permission picks, so
under Ask or Auto-Review it changes nothing, and `ask` is not a config key. At
startup the server writes one line to stderr naming the permission and sandbox it
will run with, so an editor's agent log shows which posture a launch command
produced.
Tool updates begin `pending`. `in_progress` means Core reached final dispatch;
`completed`/`failed` and typed image blocks come from the actual Core result.
`session/request_permission` correlates a private JSON-RPC ID to the same
Runtime-minted pending approval and Core execution ID. Only an exact live
`allow-once` response can release that waiter; wrong IDs are ignored, invalid
options deny, and cancellation withdraws it. ACP grants neither remembered
permissions nor Native capabilities. Core typed rules, strict hooks, repo law,
Headless Auto-Review and hard floors remain in force, including the absence of a
workspace-write carve-out; a guardian-only decision refuses on this host.
Replay uses the existing bounded event reader and sequence deduplication. A
replay gap, closed owner or terminal store fault is an explicit error, never a
re-execution. Input is serviced between replayed events; each transport write has a 30-second
deadline. Cancel, EOF and writer
failure interrupt only this transport's claimed Core turn; settlement uses its
actual terminal receipt and preserves any completed effects. An unconfirmed
cancellation does not fabricate success. `stopReason` is `end_turn`, `cancelled`,
or typed `max_turn_requests`; Core failure remains an error. Prompts use at most
50 model steps (a lower configured limit still wins) and Core's bounded final
report response; this profile does not dispatch autonomous goal continuations.
An oversized or non-UTF8 ACP input frame produces one bounded `-32700` error.
After an oversized frame, input is discarded through its actual newline before
another request can be admitted; cancellation preserves that discard state.
This applies to standalone ACP and an authenticated guest of a normal Runtime
owner. Other input I/O failures, owner-reply validation failures and compatibility
control framing failures end the stream. An oversized ACP response becomes a
bounded `-32603` error, with a null ID if echoing the original ID would also
exceed the limit. Oversized notifications refuse before writing and use the
existing operation cleanup; they do not report truncated success.
When no process owns the Runtime store, ACP claims its own exclusive canonical
Runtime owner. When a non-ACP Runtime host already owns it (the desktop app,
`codewhale app-server --http` or `codewhale serve --http`), ACP attaches to that
owner as an authenticated guest over its local control socket and serves the
editor from the owner's store. If that owner exits, the ACP process ends with
an error naming the exit; pending operation outcomes may be uncertain and are
not replayed. A second `codewhale serve --acp` in a store another ACP server
already owns refuses at startup and leaves the first serving. A saved
conversation bound to another Runtime store also refuses; ACP does not copy a
live conversation into a random store. This does not expose every `/v1/*`
steering, job or control method to an ACP editor. Use
`codewhale app-server --http` for that full runtime API.
## Capability endpoint: `codewhale doctor --json`
Returns a JSON object describing the current installation's readiness state.
Suitable for health-check polling from a macOS workbench. This command is
strictly structural and offline: it does not load workspace credential
`.env` files, inspect credential environment values, open secret/OAuth files,
probe an OS keyring, contact providers, or start MCP processes.
```bash
codewhale doctor --json
```
### Response schema (key fields)
| Field | Type | Description |
|---|---|---|
| `version` | string | Installed version (e.g. `"0.8.9"`) |
| `config_path` | string | Resolved config file path |
| `config_present` | bool | Whether the config file exists |
| `paths` | object | Canonical config, settings, state, sessions, logs, automations, and secrets paths |
| `secret_backend` | object | Metadata-only file-store shape, or literal `unknown` / `not_probed` for system and unsupported backends |
| `workspace` | string | Default workspace directory |
| `legacy_state.primary_root` | string | Primary Codewhale state root inspected for known state paths |
| `legacy_state.legacy_root` | string | Legacy `.deepseek` state root inspected for known state paths |
| `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 |
| `legacy_state.legacy_only_count` | number | Count of known state paths present only under the legacy root |
| `legacy_state.dual_present_count` | number | Count of known state paths present under both primary and legacy roots |
| `legacy_state.entries` | array | Per-path migration status: `{name, primary_present, legacy_present, status}` |
| `legacy_state.session_recovery.status` | string | `isolated`, `no_legacy_sessions`, `migration_pending`, `migration_incomplete`, `migration_complete`, or `scan_failed` |
| `legacy_state.session_recovery.read_only` | bool | Always true; doctor never invokes session migration or modifies either session directory |
| `legacy_state.session_recovery.chat_contents_read` | bool | Always false; comparison is based only on top-level `.json` filenames and filesystem metadata |
| `legacy_state.session_recovery.checkpoint_internals_scanned` | bool | Always false; `sessions/checkpoints/` and all other directories are skipped |
| `legacy_state.session_recovery.recoverable_files` | array | Bounded sample of up to 100 missing destination filenames with source and destination paths; no chat payloads |
| `legacy_state.session_recovery.recoverable_file_count` | number | Total missing destination filename count, including entries beyond the bounded sample |
| `legacy_state.session_recovery.recoverable_files_truncated` | bool | Whether more than 100 recoverable filenames were found |
| `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 |
| `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 |
| `api_key.availability` | string | Literal `present`, `not_required`, `not_probed`, `unavailable`, or `unknown`; only `present` and `not_required` certify structural Setup/Fleet credential readiness |
| `base_url` | string | Provider URL authority only (`scheme://host[:explicit-port]`); userinfo, path, query, and fragment are omitted |
| `default_text_model` | string | Default model |
| `memory.enabled` | bool | Whether the memory feature is on |
| `memory.path` | string | Path to memory file |
| `memory.file_present` | bool | Whether memory file exists |
| `mcp.config_path` | string | MCP config file path |
| `mcp.present` | bool | Whether MCP config exists |
| `mcp.probe_scope` | string | `configuration`; doctor does not start MCP servers |
| `mcp.live_health_checked` | bool | Always false for doctor JSON |
| `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` |
| `skills.selected` | string | Resolved skills directory |
| `skills.global.path` / `.present` / `.count` | — | Codewhale global skills dir (`~/.codewhale/skills`, with legacy `~/.deepseek/skills` support) |
| `skills.agents.path` / `.present` / `.count` | — | Workspace `.agents/skills/` dir |
| `skills.agents_global.path` / `.present` / `.count` | — | agentskills.io global skills dir (`~/.agents/skills`) |
| `skills.local.path` / `.present` / `.count` | — | `skills/` dir |
| `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir |
| `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir |
| `tools.path` / `.present` / `.count` | — | Global tools directory |
| `plugins.path` / `.present` / `.count` | — | Global plugins directory |
| `sandbox.available` | bool | Whether sandbox is supported on this OS |
| `sandbox.kind` | string or null | Sandbox kind (e.g. `"macos_seatbelt"`) |
| `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir |
| `storage.stash.path` / `.present` / `.count` | — | Composer stash |
### Example
```json
{
"version": "0.8.9",
"config_path": "/Users/you/.codewhale/config.toml",
"config_present": true,
"workspace": "/Users/you/projects/codewhale-tui",
"api_key": {
"source": "secret_store_unprobed",
"availability": "not_probed"
},
"base_url": "https://api.deepseek.com",
"default_text_model": "deepseek-v4-pro",
"memory": {
"enabled": false,
"path": "/Users/you/.codewhale/memory.md",
"file_present": true
},
"mcp": {
"config_path": "/Users/you/.codewhale/mcp.json",
"present": true,
"servers": [
{"name": "filesystem", "enabled": true, "transport": "stdio", "args_count": 2, "env_count": 0, "status": "ok"}
]
},
"sandbox": {
"available": true,
"kind": "macos_seatbelt"
}
}
```
The mechanically checked [HTTP route inventory](RUNTIME_ROUTES.md) lists literal
paths and methods registered by current source, including native-client routes
whose payload contracts are specific to that client.
## HTTP/SSE runtime API: `codewhale app-server --http`
```bash
codewhale app-server --http [--host 127.0.0.1] [--port 7878] [--workers 2] [--auth-token TOKEN] [--insecure-no-auth]
codewhale app-server --mobile [--host 127.0.0.1] [--port 7878] [--auth-token TOKEN]
codewhale app-server --mobile --host ::1 [--port 7878] [--insecure-no-auth]
codewhale web [--port 7878]
# Compatibility aliases — identical server, serve flag names:
codewhale serve --http [...] [--insecure]
codewhale serve --mobile [...] [--insecure]
```
Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8).
A Unix supervisor may pass a listening TCP socket as descriptor 3 and set
`CODEWHALE_RUNTIME_LISTEN_FD=3` for `serve --http` or `app-server --http`.
The Engine captures ownership before config discovery or child execution,
sets close-on-exec, and validates TCP/listening state, disabled port sharing,
and the exact numeric loopback host and nonzero port requested on the CLI.
Insecure unauthenticated mode is refused. Malformed, closed, non-listening,
wrong-address or already-consumed descriptors fail startup; only an absent
marker permits an ordinary bind. An inherited listener cannot attach to a live
Runtime owner, while the existing authenticated dead-owner recovery remains.
The supervisor must retain its socket across normal Engine restarts and pass
only the intended descriptor to the Engine. The Engine drops its descriptor on
exit and never shuts down the supervisor's shared socket. Permanent shutdown
must stop both the service and any socket-activation unit; stopping just the
service can allow incoming traffic to reactivate it. This descriptor contract
does not itself validate a supervisor's systemd PID or descriptor-count metadata.
The server binds to `localhost` by default. Configuration is via CLI flags —
there is no `[app_server]` config section.
`/v1/*` routes require a bearer token unless `codewhale app-server` is started
with `--insecure-no-auth` on a loopback bind such as `127.0.0.1`. Mobile mode
is loopback-only: non-loopback hosts are rejected until Runtime has a TLS or
verified-overlay transport boundary. The `codewhale serve` compatibility aliases
use `--insecure` for the same loopback escape hatch.
Pass `--auth-token TOKEN` or set `CODEWHALE_RUNTIME_TOKEN=TOKEN` before starting
the server; `DEEPSEEK_RUNTIME_TOKEN` remains a compatibility alias. If neither
is set, the process generates a Runtime token for that process and does **not**
print it. `/health`, `/v1/runtime/info`, and an enabled static client shell
remain public; Runtime mutations and thread data stay behind `/v1/*`
authentication. `/mobile` returns 404 when mobile mode is disabled and serves
the unchanged static shell when it is enabled.
Authenticated clients can provide the token as `Authorization: Bearer TOKEN`,
`X-Codewhale-Runtime-Token: TOKEN`, the legacy
`X-DeepSeek-Runtime-Token: TOKEN`. Query-string and raw Runtime-token cookie
authentication are not supported.
### Local browser client
`codewhale web` starts the canonical Runtime API on `127.0.0.1`, serves
dependency-free assets embedded in the binary, prints a single-use launch URL,
and asks the operating system to open that URL in the default browser. If the
browser does not open, the printed URL remains usable for ten minutes. The
command cannot bind to a non-loopback host and cannot run with Runtime auth
disabled.
The browser-launch URL contains a random, short-lived, one-time bootstrap
capability, never the Runtime token. A loopback request exchanges that
capability for a
`codewhale_web_session=…; HttpOnly; SameSite=Strict; Path=/` cookie backed by a
single process-local server session that expires 12 hours after the server
process starts, consumes the capability immediately, and redirects to `/`.
Reused, expired, malformed, or
non-loopback bootstrap attempts fail closed. The Runtime bearer token is not
placed in rendered HTML, browser storage, logs, URL queries/fragments, or
browser-launch arguments. The one-time bootstrap capability is printed in the
local terminal and transits the OS browser launcher's argument list. A same-user
process could race the browser to the exchange, which is why the capability is
single-use, loopback-only, and expires after ten minutes — and why a same-user
attacker has strictly easier local avenues than this race.
Web fetches require the session cookie plus an origin-scoped request proof;
streams use a fresh single-use ticket. The initial redirect carries the proof
in a fragment, which the client removes and saves in origin-scoped
`sessionStorage`, so a reload of that tab keeps working when the browser
allows session storage. The server never puts
the proof in a page: the session cookie is host-scoped, not port-scoped, so a
listener on another loopback port can receive it, and the cookie alone must
not be enough to recover the proof. A new tab opened by typing the address
therefore has no proof; use the original tab, duplicate it, or restart
`codewhale web` for a fresh one-time session. The page uses `no-store`,
disallows framing, and grants no cross-origin read access. Nothing extends the
server session or replaces an expired cookie.
Cross-origin Fetch Metadata or a mismatched Origin is rejected on web API
requests. Explicit bearer and Runtime-token header clients keep their existing
behavior. Transient stream-ticket failures retry with capped backoff; HTTP
401/403 stops ticket retries until a fresh session is opened.
The embedded client provides a responsive thread/search rail, Runtime-owned
session facts, transcript and tool receipts, and a bottom composer. It can
create, select, rename, and archive threads; choose a provider and model for a
new thread without changing Runtime defaults; start or steer turns; interrupt
work; resolve approvals; and answer Runtime user-input requests. Selection
loads `GET /v1/threads/{id}` first, then opens the replayable event stream with
`since_seq=latest_seq`; reconnection advances from the newest accepted sequence
and drops duplicates or events from a stale selection. The thread detail
snapshot includes `pending_approvals`, `pending_user_inputs`, and
`pending_dynamic_tool_calls`; clients must hydrate those fields before
subscribing so a reload cannot strand work whose request event is at or before
`latest_seq`. Resolution is also published as `approval.decided`,
`user_input.answered`, `user_input.canceled`, `tool_call.resolved`,
`tool_call.canceled`, or `tool_call.timeout` for already-connected clients.
An existing thread's model, mode, permission posture, workspace, and branch are
display-only in this client. Files/Changes, PTY/terminal, preview, artifacts,
provider login or global-default switching, Fleet creation, and
undo/retry/restore controls are not built into this page. The native desktop
client covers them through the workspace-file, turn-artifact, terminal and
workspace-restore routes documented here.
### Mobile control page
`codewhale serve --mobile` starts the same HTTP/SSE runtime API and serves a
phone-friendly control page at `/mobile`. It binds only to loopback
(`127.0.0.1` or `::1`); a non-loopback host is rejected because this Runtime
surface does not yet provide TLS or a verified overlay transport. The static
HTML page contains no Runtime bearer and is not itself token-gated. When
Runtime auth is enabled, the CLI prints a short-lived, single-use loopback
bootstrap URL. That capability creates a 30-minute, process-local
`Max-Age=1800; HttpOnly; SameSite=Strict` mobile session cookie plus origin-scoped browser
proofs. A sibling port that receives the host-scoped cookie cannot use it by
itself. The page can also exchange an explicitly entered bearer once, then
clears it rather than storing it in browser storage or a cookie. EventSource
connections use separate short-lived, single-use stream tickets.
The mobile page can list/create threads, send prompts, follow live SSE events,
steer or interrupt an active turn, and resolve normal tool approvals through
`POST /v1/approvals/{approval_id}`. It is a local-only convenience surface;
do not expose it directly to another device or public network until Runtime has
a TLS or verified transport boundary.
### Endpoints
**Health**
- `GET /health`
**Sessions** (durable session manager)
- `GET /v1/sessions?limit=50&search=&include_archived=false&archived_only=false&workspace=&sort=recent|name|size`
- `GET /v1/sessions/summary?…` (same query params; projected row shape)
- `GET /v1/sessions/{id}` (add `?peek=true&entries=12` for a bounded, redacted
read-only peek instead of the full transcript). The full response carries
`turn_outcomes` when a turn ended `Failed`: `{ status, error, ended_at,
after_message_count }` per failure, oldest first, bounded to 64, with the
error text the transcript showed and secrets redacted
- `PATCH /v1/sessions/{id}` (`{ "title"?: string, "archived"?: bool }`)
- `DELETE /v1/sessions/{id}`
- `POST /v1/sessions/{id}/resume-thread` returns the open thread that already
holds the whole saved session (`200`), or seeds a new thread from it (`201`)
when none does, including when the session grew after that thread opened it.
Without a `mode` in the body, a new thread takes the saved mode; a saved mode
that is not a Runtime mode, such as `exec`, resumes as `agent`. The new
thread's tool results are stored masked as live receipts are: a
`request_user_input` answer becomes `User input submitted` and configured
credentials are redacted. `GET /v1/sessions/{id}` still returns them unmasked.
- `GET /v1/sessions/{id}/artifacts` and `GET /v1/sessions/{id}/artifacts/{artifact_id}?offset=&limit=`
(see workspace files and session artifacts above; runtime-turn spills are
read through `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}`)
- `POST /v1/sessions` (`{ "thread_id": string, "title"?: string }`) exports a
thread as a saved session. It is idempotent: it writes only the document
whose id is derived from the thread, creating it the first time (`201`) and
updating it after (`200`), so a retry never makes a duplicate. A session the
thread was resumed from is left unchanged.
- `PUT /v1/sessions` (`{ "thread_id"?: string, "session_id"?: string }`) saves
a thread's live conversation. Naming a `session_id` that another thread is
bound to returns `409 Conflict`.
- `GET /v1/sessions/repair` returns the last session-store repair summary, or
`null` if none has run
Sessions and threads answer the same `include_archived` / `archived_only` pair
with the same meaning, and `search` is the same fuzzy match (title, id,
workspace — substring, then subsequence) the TUI session picker and the workbar
Sessions list use. All three surfaces run one projection
(`crates/engine/src/session_projection.rs`), so a listing cannot differ between
the terminal and the dashboard.
`GET /v1/sessions/summary` returns rows that are field-compatible with
`GET /v1/threads/summary` — `id`, `title`, `preview`, `model`, `mode`,
`workspace`, `archived`, `updated_at` — plus `message_count`, `total_tokens`,
`created_at`, `parent_session_id`, and `is_current`. One caveat stated plainly:
`preview` is the session's recorded **title**, not its last message. Session
metadata does not store a last message, and reading every transcript to
synthesise one would make a list view an unbounded read. Full transcript
preview lives in the TUI session picker, which reads one selected session.
Metadata inspection reads at most 16 MiB from each regular session file. A
resource refusal returns `413` with code `session_metadata_too_large` from
catalog and lifecycle routes; the source remains unchanged. A catalog with
unreadable records returns an error rather than an incomplete successful list.
The terminal picker retains readable rows and reports its inspection warning.
These limits cover metadata inspection, not full transcript loading.
Saved-document and checkpoint publication uses a native source captured before
snapshot preparation or queueing. Both physical records must still match under
the existing session write admission before archival, pruning, replacement or
checkpoint consumption. A mismatch refuses and retains recovery. Optional
serialized `publication_id` metadata identifies a generation; it does not grant
write authority. An unknown checkpoint needs explicit held resume/attach/promotion
before automatic completion can consume it. Runtime prefix-restore validation
remains distinct from the full-document predicate required for a later write.
Optional usage-store damage remains visible as incomplete coverage while valid
recovery data can be read without a publication receipt. Known retirement and
changed raw records still refuse; this read result cannot authorize a save.
`PATCH /v1/sessions/{id}` renames and/or archives a saved session and returns a
lifecycle receipt shaped like the thread patch receipt:
```json
{
"session": { "id": "…", "title": "Renamed", "archived": true, "…": "…" },
"changes": { "title": "Renamed", "archived": true }
}
```
`changes` lists only what actually moved, so a no-op patch is distinguishable
from an applied one. Archiving is durable and reversible: an archived session
stays on disk and stays loadable, disappears from default listings, and is
never chosen by `--continue` or by auto-resume. The route is the same writer
the TUI picker (`e`) and `/sessions archive ` use — there is no second
archive notion.
While a session is open in an interactive Codewhale process, that process holds
the authoritative copy in memory and rewrites the whole document on its next
autosave. `PATCH`, `PUT` and `DELETE` therefore fail closed on it with
`409 Conflict` rather than writing something that would be silently reverted.
Change it in the terminal instead. The open process holds a lock on the
session (`sessions/.late-usage/.live`), so this holds whether the request
reaches the API inside that process or a separate `codewhale serve`.
The session store is repaired in the background at each launch and each
`codewhale serve` start. The repair gives a "Recovered:" session to each thread
in a Runtime store that no session is bound to. It unbinds threads whose session
document is gone; they then load from their own turns. It moves unreadable
documents, empty unbound stores, and old artifact directories that no session
names to `sessions/.set-aside//`, and writes a `MANIFEST.jsonl` there.
Nothing is deleted. `GET /v1/sessions/repair` and `codewhale doctor` report the
last run; `codewhale doctor --repair-sessions [--dry-run]` runs one on demand.
`GET /v1/sessions/{id}?peek=true` returns a bounded, redacted, read-only view
instead of the transcript: at most 12 entries of at most 400 characters each
(`&entries=N` lowers the budget, never raises it past the cap), tool calls and
results summarised to a name and a size rather than inlined, and
credential-shaped substrings masked. `omitted_before` reports how many earlier
messages were dropped. The payload carries `"live": false` and deliberately has
no turn status, `running`, or `active` field — a saved session is a recording,
and live state comes only from a resumed thread's SSE stream.
**Threads** (durable runtime data model)
- `GET /v1/threads?limit=50&include_archived=false&archived_only=false`
- `GET /v1/threads/summary?limit=50&search=&include_archived=false&archived_only=false&thread_ids=,`
- `GET /v1/threads/running`
- `GET /v1/goals?limit=50&cursor=&status=` — read-only index of
the per-thread goal records across threads, newest update first. Returns
`{"goals": [ThreadGoal...], "next_cursor": ""}`; `next_cursor` is
absent on the last page. `limit` is 1-200 (default 50); `status` is one of
`active`, `paused`, `blocked`, `usage_limited`, `budget_limited`, `complete`;
goals whose thread no longer exists are omitted; a bad `status` or `cursor`, or a cursor issued under a different `status`, is `400`. Same auth as every other `/v1` read.
Goal creation, completion and removal stay on `/v1/threads/{id}/goal`.
- `GET /v1/threads/{id}/notices`
- `DELETE /v1/threads/{id}/notices/{notice_id}`
- `POST /v1/threads`
- `GET /v1/threads/{id}`
- `PATCH /v1/threads/{id}` (see body shape below)
- `POST /v1/threads/{id}/resume`
- `DELETE /v1/threads/{id}` — removes the thread with its turns, items,
events, goal and approval grants, and stops its cached engine. A thread
with a running turn, or one that an active automation or a pending
trigger delivers into, answers `409` naming them. The saved session bound to it is
deleted too unless another thread is bound to the same session or an open
terminal holds it; the answer
`{deleted, session_id?, session_deleted, session_kept?, session_error?}`
says which. Tasks, automations and child threads that name it keep the
stale id.
- `POST /v1/threads/{id}/fork` (`POST /v1/threads` also accepts
`parent_thread_id` naming an existing thread)
- `POST /v1/threads/{id}/read` — record the newest item a client has shown
(body `{"item_id": "- "}`; see "Read markers" below)
- `GET /v1/threads/{id}/receipt` — what the thread did, one entry per action
(read-only; shape in [RECEIPTS.md](RECEIPTS.md))
- `GET /v1/threads/{id}/turns/{turn_id}/receipt` — the same, for one turn;
`404` for an unknown thread or a turn that is not this thread's
`thread_ids` is a comma-separated list of up to 200 IDs (each at most 128 bytes).
The route filters exact IDs before applying `limit`, preserving newest-first
order. This is a selection filter, not an ownership or authorization check;
the Runtime bearer token remains the access boundary.
`POST /v1/threads` accepts optional execution defaults in addition to the
provider, model, workspace, and permission fields:
```json
{
"model_provider": "openai-codex",
"model": "gpt-5.6",
"reasoning_effort": "high",
"allowed_tools": ["read_file", "search"]
}
```
`reasoning_effort` uses the canonical Runtime vocabulary (`auto`, `off`,
`low`, `medium`, `high`, `xhigh`, `ultra`, or `max`; documented compatibility
aliases are accepted and persisted canonically). `allowed_tools` is a
model-visible allowlist. Omitting it keeps the normal configured catalog; an
explicit empty array (`"allowed_tools": []`) exposes no tools to the model.
Both fields are additive: older thread records and clients that omit them
retain their previous behavior.
### Sessions on other agents (external agent threads)
`POST /v1/threads` with `executor` runs the thread's turns on an agent CLI
already installed and signed in on this computer, over Agent Client Protocol,
instead of Codewhale's own agent:
```json
{
"workspace": "/Users/you/projects/app",
"permission_posture": "ask",
"executor": { "kind": "acp", "agent_id": "opencode", "config": { "model": "
" } }
}
```
`agent_id` is an `id` from `GET /v1/agents/external` whose row says
`runnable: true`; `kind` is always `acp`; `config.model` is optional and is the
agent's own config value, never a Codewhale provider route. When the agent cannot be driven the
request is refused with a sentence and a stable `error.code`:
`unknown_agent`, `not_installed`, `adapter_missing`, `below_min_version` or
`no_acp` (`424`), and `needs_sign_in` (`409`) when the agent refuses to start a
session because the person is not signed in to it. Turn starts and `resume`
use the same codes. Codewhale never
downloads an agent or adapter.
The thread record carries the executor; native threads have none:
```json
"executor": { "version": 1, "kind": "acp", "agent_id": "opencode",
"agent_name": "OpenCode", "agent_version": "1.15.10", "protocol_version": 1,
"foreign_session_id": "ses_…", "model": null }
```
`agent_name`, `agent_version`, `protocol_version` and `foreign_session_id` are
filled when the agent first starts (the first turn, or `POST
/v1/threads/{id}/resume`). The record never holds an environment value, token
or credential: the agent uses its own login. `model_provider` is absent and
`model` is the agent's model value or name.
Everything else is the ordinary thread API, and the Engine's records stay the
one authority. `POST .../turns` sends the prompt as one `session/prompt`; the
agent's updates become the usual `turn.started`, `item.started`,
`item.delta` (`kind` `agent_message` or `agent_reasoning`), `item.completed`
/ `item.failed` and `turn.completed` events. Item kinds: `agent_message_chunk`
→ `agent_message`, `agent_thought_chunk` → `agent_reasoning`, tool calls of
kind `execute` → `command_execution`, `edit`/`delete`/`move` →
`file_change`, `switch_mode` → `status`, any other → `tool_call`, and a plan
→ one `status` item. Tool items carry `metadata.external_agent` with the
agent's `tool_call_id`, `kind` and `locations`. Everything the agent sends is
bounded (8 MiB per message, 256 KiB per item, 512 tool items per turn,
control characters removed) and ignored when it names another session.
The agent's `session/request_permission` becomes an ordinary
`approval.required` (with `external_agent`, `options` and `locations`) and is
answered through `POST /v1/approvals/{id}`. The answer is recorded in
`approval.decided` before the agent hears it. The thread's dial decides
what is asked:
| Dial (`permission_posture` / `mode`) | Answer to `session/request_permission` |
| --- | --- |
| `ask` | always an approval card |
| `accept_edits` | `allow_once` without asking for an `edit`, `delete` or `move` whose every location resolves inside the workspace or an added folder; an approval card otherwise |
| `auto_review` | an approval card (no model review exists for another agent) |
| `full_access` | `allow_once` (or `allow_always` if that is all it offers) without asking |
| `plan` (mode) | `reject_once` without asking, whatever the posture |
A card's Allow selects `allow_once` (`allow_always` when `remember` is set and
offered); Deny selects `reject_once` (`reject_always` when `remember`); an
answer the agent offered no option for, a withdrawn card and a stopped turn
answer `cancelled`. Automatic answers are recorded with `auto: true` and
`decided_by: "policy"`. Codewhale sees only what the agent routes through
ACP: its own tools are not sandboxed by Codewhale.
`POST .../interrupt` sends `session/cancel`, withdraws pending approvals as
cancelled and, if the agent has not stopped after 10 s, ends its process; the
turn is `interrupted`. A stop reason other than `end_turn` or `cancelled`
fails the turn with it. After a restart, the next turn or resume reopens the
same agent session with `session/resume` or `session/load` (whose replay is
not re-recorded); an agent that supports neither says so instead of starting
a new session. `steer`, `compact`, `fork`, images, dynamic tools and a
per-turn provider, model, reasoning setting, output allowance or tool allowlist
are refused on an external thread. Thread creation uses only
`executor.config.model` for the agent's model; Codewhale provider settings,
tool allowlists and system prompts are refused. `PATCH` cannot change its
model, provider, system prompt or workspace. Usage is what the agent itself reports, labelled
`agent reported`; cost is `unknown` because the agent bills through its own
account.
Three thread events describe the agent process itself: `executor.started`
(`agent_id`, `agent_name`, `agent_version`, `protocol_version`,
`foreign_session_id`, `reopened`) when the agent starts or is reopened,
`executor.stopped` (`reason`) when it exits or is stopped between turns, and
`turn.agent_usage` (`used`, `size`: the agent's own context-window token
figures, `source: "agent reported"`, `cost: "unknown"`) whenever the agent
sends ACP `usage_update`. `turn.completed` carries `turn.error` with the
reason a turn failed or was ended. Stopping an idle agent process is the
Engine call `stop_external_agent`; no HTTP route exposes it yet.
Two shapes are reserved and refused today with `error.code`
`executor_not_supported` or `environment_not_supported` (`424`), so a record
written by a later build never runs here by mistake: `executor.kind:
"engine"` (a child session run by another environment's own Engine), and
`environment: {"id", "provider_kind"}` on `POST /v1/threads` and on the
session record (absent means this computer; the names follow
`codewhale_protocol::placement`). Summary rows carry `environment` when set.
`CODEWHALE_ACP_FIXTURE_AGENT=` in the
Engine's environment adds a `fixture` agent for end-to-end proofs.
`GET /v1/threads/summary` is the read-only summary surface used by the VS Code
Agent View. `search` matches thread `id`, `title`, and `model` (and, when the
title is unset, the latest turn's input summary — the displayed title). It
does not scan turn or item bodies: `preview` is filled only after a match, so
a dashboard keystroke is not a whole-store read per thread. Each item includes
`id`, `title`, `preview`, `model`, `mode`, `archived`, `updated_at`,
`latest_turn_id`, `latest_turn_status`, `parent_thread_id` when the thread
was forked from another (absent on threads forked before the field existed),
plus workspace metadata:
```json
{
"id": "thread_...",
"title": "Implement MCP status count",
"preview": "The TUI footer should count project MCP servers...",
"model": "deepseek-v4-pro",
"mode": "agent",
"branch": "feature/runtime-api",
"head": "abc1234",
"dirty": false,
"workspace": "/Users/you/projects/codewhale",
"archived": false,
"updated_at": "2026-06-06T05:43:00Z",
"latest_turn_id": "turn_...",
"latest_turn_status": "completed"
}
```
A conversation run by an external agent also carries
`executor: {"kind": "acp", "agent_id": "opencode", "agent_name": "OpenCode"}`,
so a sidebar can label and group rows by agent; native rows omit it.
Rows also carry `last_read_item_id` and `unread` from the thread's read marker
(see "Read markers" below). Both are `null` for a thread no client has marked
read.
`branch` is resolved from the thread workspace at request time and may be
`null` when the workspace is not a Git repository or the branch cannot be read.
`head` is the current short Git commit for that workspace when available.
`dirty` is true when the workspace has staged, unstaged, or untracked changes.
`workspace` is included so editor clients can show when an agent lane is working
outside the current VS Code folder.
Thread forks are sibling runtime threads, not an in-place tree projection.
`thread.forked` events include `source_thread_id`; internal backtrack-aware
forks may also include `backtrack_depth_from_tail` and `dropped_turn_id`, and
a fork anchored to a named turn (`/fork-at-turn`) reports them with the depth
resolved from that turn, and names the first user turn it dropped (not the
anchor, which a named-turn fork keeps, and not a prompt-less turn such as a
manual compaction that sits between them).
Thread list and summary responses remain flat in v0.8.40, so clients that need
a graph should reconstruct it from events instead of assuming list order is a
complete tree.
### Read markers
`POST /v1/threads/{id}/read` with `{"item_id": "- "}` records that a
client has shown the conversation through that item, in the order
`GET /v1/threads/{id}` returns items (turn order, then item start time). A
client sends the id of the newest item it rendered when the person opens or
reads the conversation. The item must belong to the thread: an unknown thread
is `404`, and an unknown item or one from another thread is `400`. The
response is the thread record. Its `last_read` holds `item_id`, `last_in_turn`
(that item was the last item of its turn when it was marked) and
`watermark_item_id` (the last item that turn had recorded at that moment).
Marking read changes nothing else: `updated_at`, list order and the thread's
event stream are untouched, so opening a conversation never moves it to the
top of a list or makes it unread again. The marker only moves forward:
marking an item that comes before the current one leaves the marker as it is,
so a window showing an older snapshot cannot make a read conversation unread.
Forks start without a marker.
`GET /v1/threads/summary` derives `unread` from the marker: `false` while the
marked item is still the newest, `true` once any later item exists, and `null`
when the thread was never marked (older Runtimes had no marker, so existing
conversations are not all reported unread after an upgrade; clients keep their
own fallback for those). `last_read_item_id` is the item to place a "new"
divider after. Known limits: `unread` follows the items each turn records
while it runs, so a turn that has started but not yet recorded an item does not
count until it does, and a notice added to a turn after it finished (such as a
permissions change) does not count.
`GET /v1/threads/running` is the running-work accounting surface
(#6180): threads with at least one queued or in-progress turn, each with
`thread_id`, `model`, `title`, and `active_turns` (`turn_id` + `status`).
Background-capable clients use it for quit/background decisions — one call,
no inference from latest-turn status. Archive state is ignored (archiving
has no quiescence gate); an empty array means no owned work is live.
`GET /v1/threads/{id}/notices` is the per-thread active-notice surface
(#6180): the TUI-visible conditions a watch-only client must surface —
`subagent-terminal` (a child settled), `elevation-needed` (a tool call is
blocked on elevation), `model-notify` (the model asked the user to come
back) — each with `turn_id` and a `subject` id for targeting. Notices are
in-memory session state, bounded to 32 per thread (oldest evicted), and
never persisted. Clearing: elevation auto-clears when its tool call
completes; terminal/notify clear on `DELETE .../notices/{notice_id}`
(204, unknown ids 404). Unknown threads 404 on both endpoints.
`archived_only=true` returns archived threads only (mutually overrides
`include_archived`). Default behavior is unchanged: `include_archived=false`
and `archived_only=false` returns active threads. Added in v0.8.10 (#563).
`PATCH /v1/threads/{id}` body — every field is optional, missing means
"no change". At least one field must be present. `title` and `system_prompt`
accept an empty string to clear a previously-set value. Added in v0.8.10 (#562):
```json
{
"archived": true,
"allow_shell": false,
"trust_mode": false,
"auto_approve": false,
"model": "deepseek-v4-pro",
"mode": "agent",
"title": "User-set thread title",
"system_prompt": "You are a useful assistant.",
"model_provider": "custom",
"model_provider_id": "lm-studio"
}
```
A `system_prompt` change applies from the next turn: a loaded engine is
unloaded and rebuilt from the thread's history with the new prompt. Like a
`workspace` change, it is refused while the thread has a running turn.
`model_provider` switches the provider the thread's future turns use. It
takes a built-in kind (`deepseek`, `xai`, ...) or a configured route name, as
`/provider` does. `model_provider_id` names one exact `[providers.
]` table
and wins over a route name. The target route is resolved and its client
preflighted before anything is saved, so an unknown or credential-less
provider is refused and nothing changes. Without `model`, the thread takes the
new provider's default model; an `auto` thread stays `auto`. The loaded
engine and conversation history are kept, and the next turn installs the new
route.
A change of effective permission adds a completed `status` item to the thread's
latest turn and publishes a `thread.permissions_changed` event (`from`, `to`,
`source`).
**Turns** (within a thread)
- `POST /v1/threads/{id}/turns`
- `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).
- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`
- `GET /v1/threads/{id}/turns/{turn_id}/artifacts` - what the turn produced: typed references plus the workspace-delta state. See [Turn artifacts](#turn-artifacts).
- `GET /v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}?offset=&limit=&revision=` - read one reference from the workspace, the post-turn snapshot, or the session artifact directory.
- `POST /v1/threads/{id}/compact` (manual compaction)
- `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. `{"turn_id": "turn_…"}` instead names the user turn to remove, together with every turn after it, and wins over `depth`; the Runtime resolves it under the thread's restore guard, so a turn another client appends meanwhile cannot move the cut. `400` when the turn is not a user turn of that thread
- `POST /v1/threads/{id}/fork-at-turn` - fork at one named user turn (`{"turn_id": "turn_…"}`, as `GET /v1/threads/{id}` reports it). The fork *keeps* that turn and every turn before it, and drops the turns after it; naming the last turn therefore keeps the whole conversation. The receipt is `/undo`'s (`thread`, `original_user_text`, `original_user_images`), carrying the *first dropped* user turn's prompt — what was asked next, even when a prompt-less turn such as a manual `/compact` sits between — so a client can put it back in the composer for editing. The source thread, its session document and the workspace are untouched, and there is no file rollback: a fork is a sibling conversation, and rewinding the workspace would rewind the branch left behind with it. Clients should name the turn instead of computing a `depth` — the transcript they render and the turn list this cuts are not the same list (steers, image-only prompts and injected handoffs each sit on one side only), and a client-side count that is off by one forks the wrong prefix while answering `201`. `400` when the turn is not a user turn of that thread.
- `GET /v1/threads/{id}/patch-undo?turn_id=turn_…` - validates the selected restore without changing files or publishing a fork; returns `turn_ids`, the exact dropped range, `paths` needing restoration and an optional no-change `summary`. Requires the `thread_patch_undo_turn_id` capability.
- `POST /v1/threads/{id}/patch-undo` - rolls back the files the dropped turns changed, then the same fork (`{"depth": N}` or `{"turn_id": "turn_…"}`, with `turn_id` taking precedence); returns `patch_result` (`files_restored`, `summary`, `snapshot_label`) alongside the forked thread. See [Workspace restore endpoints](#workspace-restore-endpoints) for ownership, the admission and abort rules, and the `error.code` values of a refusal.
- `POST /v1/threads/{id}/file-revert` - restore exactly one file from one restore point the thread owns (`{"path", "snapshot_id", "expected_hash"}`); never forks the conversation. See [Workspace restore endpoints](#workspace-restore-endpoints).
- `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)
`POST /v1/threads/{id}/turns` accepts the same optional
`reasoning_effort` and `allowed_tools` fields as per-turn overrides:
```json
{
"prompt": "Review this change without running tools.",
"operation_key": "cwc-request-01J7Y6Q9W4",
"reasoning_effort": "max",
"allowed_tools": []
}
```
The same `model_provider` / `model_provider_id` fields on a turn route that
one turn through another provider. The saved thread keeps its provider.
Without `model`, the turn uses that provider's default model (an `auto` thread
stays `auto`). The override is always preflighted and is part of the
`operation_key` fingerprint.
Account-owned model turns require `capabilities.account_model_owner: true` before
submission. Set `account_model_owner` to the signed-in account ID, both
`model_provider` and `model_provider_id` to `codewhale`, and `model` to the exact
`provider/model` account route. The Runtime requires current model access for
that same secure account session and uses only that account credential; missing,
expired or revoked access is refused. Local keys and other configured routes are
not substitutes. A supplied profile constitution must belong to the same account.
The owner participates in the durable operation fingerprint; replay of an
already completed exact operation returns its receipt before fresh credential
admission. The account gateway resolves the selected provider's current saved
API key at use. Ordinary turns and saved Chat defaults retain their existing
behavior. The Runtime Chat relay carries the same assertion as `accountModelOwner`.
Resolution is deterministic: a turn override wins over the thread default,
which wins over the Runtime's normal configuration. For tools, reaching normal
configuration means the ordinary configured catalog; `[]` is never treated as
missing. Reasoning is normalized only after the exact provider/model route is
resolved, and `auto` remains a per-prompt reasoning decision even when the
thread uses a fixed model. The request still enters the existing
`Op::SendMessage` path and the single `Engine::run_turn` loop.
Image input uses the same turn path: `"images": [{"mime": "image/png",
"dataBase64": "..."}]`. Clients must first observe
`capabilities.turn_image_inputs: true` in `/v1/runtime/info` (or the isolated
Runtime Chat relay catalog). Older HTTP runtimes ignore unknown fields, so a
successful text response is not evidence that an attachment was accepted.
The field is omitted when empty. It is also accepted by app-server
`thread/message`, `thread/request` messages, and prompt requests; that bridge
checks the underlying Runtime capability before forwarding image bytes.
Legacy remote Work commands do not support images and explicitly refuse them.
New inline images require a named model whose exact resolved route reports
`image_input: "supported"`; Auto and unknown/unsupported image routes are
refused before classifier or provider dispatch. This does not change the
existing trusted-local attachment behavior for routes with unknown capability.
A nonempty prompt is required. Inputs are limited to 10 images, 4 MiB decoded
bytes per image, 5 MiB total, and an 8 MiB JSON body. PNG, JPEG, GIF and WebP
must have matching MIME, canonical padded base64 and valid bounded image
content: at most 8192 pixels per dimension, 33,554,432 pixels total and 64 MiB
decoder allocation. The Runtime does not fetch paths or URLs from this field.
Malformed images refuse the whole turn; callers can retain the draft for
correction. Relay command polling uses an 8 MiB response budget; the sender
must paginate by serialized bytes without advancing past unserved commands.
Accepted image bytes and order are retained in the existing turn records and
reconstructed after restart, import and fork. Retry retains those images even
when its optional `prompt` changes the text; undo responses include
`original_user_images` when present. Image-bearing records require schema v3,
which older readers refuse. Text-only records and operation fingerprints retain
their prior representation. Validated stored local images retain the existing
5 MiB per-image ceiling and prior aggregate/count semantics on import/retry;
this internal storage authority does not
relax exact model or permission checks. Image bytes, MIME and order participate in request
identity, so changing an image under the same operation key conflicts.
Compaction can summarize older context; retaining the original attachment does
not promise that every later model request includes it. Image pixels are not
subject to text-secret redaction.
`operation_key` is an optional idempotency key for clients that may lose an
HTTP response after the Runtime accepted a turn. It is scoped to the current
Runtime store and thread, may contain at most 128 UTF-8 bytes, and may not be
empty or contain surrounding whitespace or control characters. Omitting it
preserves the legacy create-a-new-turn behavior.
The first accepted request durably binds a SHA-256 fingerprint of the key to
the Runtime turn id and a canonical request fingerprint before sending the
existing `Op::SendMessage`. An exact retry returns that original turn in the
normal `{ "thread": ..., "turn": ... }` response and emits no second engine
operation, item, or lifecycle sequence. Reusing the key on the same thread
with a different provider/model, prompt, reasoning policy, tool allowlist or
dynamic-tool schema, environment, or permission policy fails closed with
`409 Conflict`. The same caller key may be used independently on another
thread.
Only the scoped key fingerprint, request fingerprint, thread id, and turn id
are stored in the Runtime's private turn-operation index. The raw key is never
persisted or logged, and request bodies, credentials, and attachments are not
copied into that index. Existing thread/turn persistence remains the source of
the returned turn after a process restart.
**Exact accepted-turn lookup**
`GET /v1/threads/{id}/turn-operations/{operation_key}` uses the same Runtime
authentication as turn submission. URL-encode each path segment. It returns
`200 OK` with the existing bare `TurnRecord` (the `turn` object in the POST
response), identified by that exact thread and operation key. It does not use
the thread's latest turn or require the original request body or current route
settings to match.
- `404 Not Found`: no binding exists for that thread/key, or persisted identities
do not match. These cases share a generic response.
- `409 Conflict`: admission holds the operation claim, or its durable binding
is incomplete. Retry the lookup; this response does not authorize another turn.
- `400 Bad Request`: the thread ID or operation key is malformed. The key uses
the same 128-byte and whitespace/control-character rules as POST.
- `500 Internal Server Error`: storage or the existing claim lock cannot be
checked safely. This is not evidence that the operation is absent.
The lookup holds a shared read lock on the existing operation claim while
reading the binding and turn. It creates no files, starts no engine, emits no
events, and performs no replay or recovery. Normal Runtime startup may recover
an incomplete admission before a later lookup, but GET itself never does so.
**Approvals**
- `POST /v1/approvals/{approval_id}` with body
`{ "decision": "allow" | "deny", "remember": false, "client": "desktop" }`.
`client` is optional and names the surface that answered: `desktop`, `phone`,
`web`, `tui` or `acp`. The answer's `approval.decided` event carries it as
`responder`. It is a label the client declares, not an authenticated device
identity; an answer without it records no `responder`.
- `GET /v1/approvals?limit=` (1-500, default 100) is the read-only history:
decided and still-pending approvals across all sessions, newest ask first.
Each history row carries `approval_id`, `tool_name`, `outcome`, `asked_at` and
`decided_at` (null while pending), plus optional `decided_by` (`user`,
`session_rule`, `posture`, `host`), `target` and `summary`. `outcome` is one of
`allowed_once`, `denied`, `timeout`, `cancelled`, `unavailable`,
`retry_with_policy`, or `pending`. `standing_rule` (`allow` or `deny`) is present
only when a remembered session rule or grant answered rather than a person
(`decided_by: session_rule`); `outcome` is unchanged. `target` is the host of a
URL or a workspace-relative path (absolute paths outside the workspace are
reduced to `.../`); `summary` is a one-line description of what was
asked. Both are redacted conservatively when the ask is recorded: a shell
command keeps only the program name and plain arguments, and any quoted,
`KEY=VALUE`, credential-like or unrecognised argument replaces all arguments
with `[args redacted]`. Whitespace is collapsed and text is capped at 120
characters. Rows recorded before these fields existed
omit `target` and `summary`.
`approval_id` is minted by the Runtime, not by the model or the provider. It is
an opaque `approval_<32 hex>` capability, unique per prompt, bound to the thread
that raised it, and single-use: the Runtime removes it when the decision is
delivered, when the prompt times out, or when the turn abandons it. Clients echo
the value they were given and must not construct, derive, or guess one.
It is deliberately **not** the provider's tool-call ID. Providers restart their
call-ID counters per response, so two threads can gate calls whose raw IDs are
byte-equal; keying approvals by that value let one thread's decision settle
another thread's call. The endpoint therefore performs one exact match on the
minted ID and has no fallback: a raw tool-call ID, an expired ID, or a replayed
ID that has already been settled all return `404` and reach no engine. A `404`
means the capability is not pending — it is not evidence about how the approval
was resolved; read `approval.decided` for that.
The Engine tool-execution ID travels separately as `tool_call_id` on
`pending_approvals[]` and on the approval events. It is a correlator for
attaching a prompt to the tool row it gates, and never accepted as a decision.
Each thread-detail `pending_approvals[]` entry is
`{ "id", "turn_id", "tool_name", "input"?, "description", "intent_summary"?, "tool_call_id"?, "summary"? }`,
where `id` is the capability above. `input` holds the captured tool input. An
omitted `input` means no captured value is available; an explicit JSON `null`
remains a captured value. On the authenticated canonical thread surface,
`approval.required` carries the same captured input.
`summary` (also on `approval.required`) is
a one-line description of the gated call built from the tool name and its
arguments only, never from model text ("Search the web for 'espresso'",
"Write notes/espresso.md"); paths inside the workspace are workspace-relative.
Clients show it first and keep the raw arguments behind it. For task and
automation create/update, `summary` also names the requested trust mode,
shell, auto-approve, mode and workspace. For a shell command, task gate,
shell input or verifier whose `curl`/`wget` request body carries a credential
file or a secret-named variable, or reads stdin fed from one of those or from
`env`/`printenv`, `summary` reads ``Upload credentials (`…`) with `…` ``; the
built-in safety gate holds that call, so a session grant or saved allow rule
cannot answer it. A shell command, shell input or verifier over 64 KiB or
4,096 words whose text contains `curl` or `wget` anywhere, or one nested too
deeply to read, is held the same way under its ordinary `summary`. A shell
command containing a hidden Unicode character is refused before any
approval is created.
`"remember": true` on an `allow` records a **session grant** for that tool and
argument class (the approval grouping key: a shell command family for a
simple, known command such as `git status` whose only options are value-free
ones like `-s` or `--porcelain` — a compound, wrapper, interpreter or
unrecognised command, one with any other option, or one whose arguments are
what runs or is installed (`go run`, `make`, `git bisect`, package installs)
is granted as its full normalized command, and a
shell interact or wait call as the exact call — a patch's file set, a `fetch_url` host, an MCP tool, a `web.run` action kind — for
`open`, the hosts it opened). Computer Use consent and `app_script` calls, and
any tool without a class, are granted for the exact call only. A grant never
changes the thread's permission posture. Later matching calls on the thread are
approved without a prompt: they still emit `approval.required`, then
`approval.decided` with `"auto": true` and the `grant_id`. Creating a grant
emits `approval.grant_added` with `{ "grant": { "grant_id", "tool_name",
"scope", "summary", "granted_at" } }`; thread detail lists live grants in
`approval_grants[]`. `DELETE /v1/threads/{id}/approval-grants/{grant_id}`
revokes one (emitting `approval.grant_revoked`); the next matching call
prompts again. Archiving or deleting the thread ends all of its grants
(archiving emits `approval.grant_revoked` for each; unarchiving does not
restore them). Grants are stored in the Runtime data directory
(`approval_grants.json`). After a Runtime restart a thread's grants stay
suspended, and matching calls prompt again, until an authenticated client
resumes the thread. A grant store that cannot be read holds no grants, so every
call prompts. A forced (non-bypassable) prompt is never answered by a grant.
**User input**
- `POST /v1/user-input/{thread_id}/{input_id}` with body
`{ "answers": [{ "id": "question-id", "label": "Choice", "value": "Choice" }] }`
Submitted values are delivered to the active model turn but are deliberately
excluded from durable Runtime items and events. The settled tool item contains
only a neutral receipt and a machine-readable `response_redacted` marker. The
Runtime accepts only an exact pending `(thread_id, input_id)` request; an
unknown, concurrently settling, or already settled id returns 404 and is never
placed in the engine mailbox. It commits the secret-free
`user_input.answered` receipt before removing the snapshot-authoritative prompt
or delivering the answer to the engine. That settlement runs independently of
the HTTP connection, so disconnecting after submission cannot leave a prompt
half accepted. Terminal-turn cancellation follows the same receipt-before-
removal ordering through `user_input.canceled`.
**Client-executed dynamic tools**
- `POST /v1/threads/{thread_id}/turns/{turn_id}/tool-calls/{call_id}/result`
The thread and turn in the result route must match the pending call. A call is
settled at most once; wrong-route and duplicate results return 404. Terminal
lifecycle events carry identifiers and status only, never tool result content.
The Runtime commits the terminal lifecycle event before making a submitted
result available to the model. Result delivery, timeout, and terminal-turn
cancellation race through one settlement owner, so exactly one of these events
is durable for a call:
- `tool_call.requested` — the typed client-executed call became pending;
- `tool_call.resolved` — a result was durably accepted by the Runtime
(`result_accepted: true`; `success` is result metadata, but result content is
excluded);
- `tool_call.timeout` — no result won before the bounded wait expired;
- `tool_call.canceled` — the turn terminated before a submitted result won.
HTTP `202 Accepted` and `tool_call.resolved` share that durable-acceptance
meaning. Neither claims that the model consumed the result: a concurrent turn
shutdown may close the model receiver after acceptance. Once the Runtime has
accepted the result, that call is terminal and a duplicate result returns 404.
**Events** (SSE replay + live stream)
- `GET /v1/threads/{id}/events?since_seq=&replay_limit=&progress=true`
Cursors:
- `since_seq` is the per-thread cursor: events with `seq > since_seq` are sent.
Omitted (and no `Last-Event-ID`), the stream starts from the beginning of the
thread's history.
- Every journal frame carries `id: `, so a browser `EventSource` can
resume through the `Last-Event-ID` header it sends on reconnect. An explicit
`since_seq` wins over the header, so a deliberate replay from `0` is never
overridden by a stale id. A header value that is not a decimal integer is
ignored.
- `replay_limit` (at most 4096) returns only the newest tail of the requested
history; `previous_seq` on the first returned event advances past exactly the
omitted history.
Durable history parsing runs off the async server workers and reaches SSE in
bounded batches of at most 256 events through a backpressured channel. Broadcast
delivery is only a wake-up optimization: a lagged receiver opens the same
bounded durable replay from its last accepted cursor.
`progress=true` adds `stream.progress` transport frames
(`{schema_version, event, kind, thread_id, seq, state}`, `state` is
`replaying` or `live`) at the current cursor and advertises them with
`x-codewhale-event-progress: 1`. The stream reports `live` only after durable
history and the already-queued live tail are both drained; broadcast-lag
recovery returns it to `replaying`. Progress frames never carry a new sequence
number.
Failures before the stream opens are ordinary HTTP errors with the JSON error
body, never SSE:
| Status | When |
| --- | --- |
| `401` / `403` | Missing or wrong Runtime credential |
| `404` | Unknown thread |
| `400` | `replay_limit` above 4096 |
| `500` | The durable history could not be opened (including a replay worker crash before the first cursor) |
Once the response is `200`, every end the server chooses is a final
`stream.end` frame; see [Ending and resuming a thread
stream](#ending-and-resuming-a-thread-stream).
**Snapshots** (side-git restore point listing + restore)
- `GET /v1/snapshots?limit=20&thread_id=`
- `POST /v1/snapshots/{id}/restore?thread_id=`
`/v1/snapshots` lists recent side-git restore points for the runtime workspace.
`limit` defaults to `20` and must be between `1` and `100`. `POST
/v1/snapshots/{id}/restore` restores workspace files from the snapshot and
returns `{"restored": ""}`. It is the direct operator surface for
the server's own workspace (the same action as the TUI's `/restore `): it is
gated by the Runtime API bearer token, not by any thread's trust flag, and it
is refused with `409` while a turn is active in an overlapping workspace (see
below). A `pre-restore:` safety snapshot is taken first. With `thread_id`
(capability `thread_scoped_snapshots`) both routes act on that thread's
recorded workspace instead, under the same admission rule; an unknown thread
answers `404` and a missing or changed workspace `409`
`thread_workspace_unavailable`.
```json
[
{
"id": "snap_...",
"label": "post-turn:1",
"timestamp": 1780730580
}
]
```
### Workspace restore endpoints
Three routes mutate workspace files from side-git snapshots. They share one
admission rule and one safety net, and they differ in scope and trust.
| Route | Scope | Trust | Forks the thread |
| --- | --- | --- | --- |
| `POST /v1/snapshots/{id}/restore` | whole server workspace, or the `thread_id` workspace | bearer token only (operator action) | no |
| `POST /v1/threads/{id}/patch-undo` | the files the dropped turns changed | none; only files the dropped turns' own tool calls wrote, still holding the bytes the turn left, are restored | yes |
| `POST /v1/threads/{id}/file-revert` | exactly one regular file | none; the restore point must be one the thread owns and `expected_hash` must match | no |
**Admission.** A restore reserves the same admission the Runtime uses for
config reloads and session checkpoints, so no new turn starts and no saved
history changes while files are being rewritten. If any thread already has an
active turn in the same workspace, a nested checkout of it, or a parent of it,
the request is refused with `409` and the message `already has an active turn`.
The reservation is owned by the worker performing the Git mutation, so a client
that disconnects mid-request cannot release it early; the operation completes
or fails as a whole. Concurrent restores serialize. The reservation is
runtime-wide: while a restore's safety snapshot and checkout run, new turns,
steering, compaction and user-input delivery on every thread wait for it to
finish, so a large workspace can add seconds of latency elsewhere during a
restore. A thread whose workspace directory is not available (unmounted
volume, disconnected share, missing directory) is refused with `409` rather
than treated as having nothing to restore.
**Ownership.** A thread owns exactly the workspace restore points recorded on
its own turns. While a turn runs, the engine reports each snapshot it takes —
`pre_turn` before the turn, `tool` before and `post_tool` after each tool call
that may write (every call that is not read-only: file tools, shell commands,
programs, write-capable MCP tools), `post_turn` when it ends (always before
the turn settles) — and the Runtime appends it to the turn record's
`workspace_snapshots`, in order:
```json
"workspace_snapshots": [
{ "kind": "pre_turn", "snapshot_id": "", "tree_id": "", "session_id": "thr_1a2b3c4d" },
{ "kind": "tool", "snapshot_id": "", "tree_id": "", "session_id": "thr_1a2b3c4d", "tool_call_id": "call_…", "write_paths": ["src/lib.rs"], "changed_paths": [] },
{ "kind": "post_tool", "snapshot_id": "", "tree_id": "", "session_id": "thr_1a2b3c4d", "tool_call_id": "call_…", "changed_paths": ["src/lib.rs"] },
{ "kind": "post_turn", "snapshot_id": "", "tree_id": "", "session_id": "thr_1a2b3c4d", "changed_paths": [] }
]
```
`changed_paths` lists the workspace-relative paths whose content changed since
the turn's previous receipt — what happened in the span the receipt closes; it
is absent on `pre_turn` and when it could not be computed (a snapshot in
between failed). `write_paths` is set on the `tool` receipt of a file tool
(`write_file`, `edit_file`, `apply_patch`) to the paths the call declared, as
it named them; a tool without it (a shell command) may write any path. On a
user shell turn the `pre_turn` receipt carries the command's `tool_call_id`,
since the command runs from it to `post_turn`.
Each receipt is also published as a `turn.workspace_snapshot` event (payload:
the receipt). The engine runs every Runtime thread under the thread's own id,
across restarts and engine eviction, so `session_id` is the thread id for turns
the thread ran itself; it does not follow the thread's saved-session binding
(`PUT`/`POST /v1/sessions`, resume), which only names a document. A fork clones
its source's turn records and so owns the restore points of the turns it
inherited. Another thread's, or a TUI session's, snapshots in the same
workspace are never candidates. `tree_id` is the durable identity: a prune
rebuilds the side repo and rewrites every commit id but keeps each tree, and a
restore point resolves only to a stored snapshot with the same tree, session
tag and kind. The count prune after each snapshot keeps the newest 50
snapshots plus the newest 50 turn boundaries (`pre-turn:`/`post-turn:`), so a
turn with more tool calls than that, or a burst from another thread, never
pushes out a recent turn's own restore points. Turns recorded before receipts existed, turns imported by
`resume-thread`, and turns run with snapshots off or unavailable have none.
**Safety net.** Every restore first records a `pre-restore:` snapshot
of the current workspace. That label is never a `/undo`, `patch-undo` or
`file-revert` candidate, so the net does not change what later undos select.
For `file-revert` the backup is mandatory: if it cannot be written, or the
requested file is excluded from it (for example by `.gitignore`), the request
fails and nothing is changed.
**`patch-undo`.** Undoes whole turns. A named `turn_id` removes that user turn and every turn after it, under the same restore admission as `/undo`. Clients must require `thread_patch_undo_turn_id` before sending selected-turn requests: older Engines may ignore that field. Preview with `GET`, then send its exact `turn_ids` as `expected_turn_ids` and `paths` as `expected_paths` on `POST`; `409 undo_selection_changed` refuses an altered range or file plan before file changes. A preview does not reserve future execution or promise idempotency. The mutation is not automatically retryable after a lost response.
For each dropped turn's `pre_turn` →
`post_turn` window, the paths that differ between the two snapshots must all be
the turn's own: changed only in the span of one of the turn's tool calls (a
`tool` → `post_tool` span, or a shell turn's whole window), and, inside a file
tool's span, a path that call declared. A path that changed while none of the
turn's tools could have written it — another thread, an editor, a background
process — is someone else's change, and the undo is refused rather than revert
it. Each of the turn's paths goes back to its content before the first dropped
turn that changed it, and nothing else is touched, so later work by the user or
another thread in the same workspace survives. Then the conversation forks
exactly as `/undo` does.
Snapshots never hold paths excluded by the workspace's `.gitignore` files or
the built-in snapshot exclusions (`node_modules/`, `target/`, `dist/`, build
caches, binary artifacts), nor paths outside the workspace. A dropped file-tool
call that declared such a path is refused with `path_not_snapshotted`, because
no snapshot can put it back. A shell command declares no paths: what it writes
under an excluded path (build output, dependency installs) is outside what
`patch-undo` restores and is not reported. `201` means either files were restored (`files_restored: true`, one
` ` line per file in `summary`, `snapshot_label` naming the
pre-turn snapshot) or there was provably nothing to restore
(`files_restored: false`): every dropped turn ran here without a tool call,
changed no file, or its files are already back at their pre-turn content.
Anything that cannot be restored aborts the whole undo with `409`, nothing is
changed and no fork is published; `error.code` says why:
| `error.code` | Meaning |
| --- | --- |
| `restore_point_unavailable` | a dropped turn that may have changed files has no complete recorded restore point (older record, imported by `resume-thread`, snapshots off, or the snapshot failed) |
| `restore_point_pruned` | the restore point is no longer in the snapshot store |
| `path_not_snapshotted` | a dropped file-tool call wrote a path snapshots do not hold (ignored, built-in exclusion, or outside the workspace) |
| `workspace_changed_since_turn` | a path the turns changed was changed afterwards (or between two dropped turns), a path changed while a dropped turn ran but outside its own tool calls, or a path that is not a regular file |
| `workspace_unavailable` | the workspace directory is not available |
For the first four a client can offer the conversation-only
`POST /v1/threads/{id}/undo` instead, and `file-revert` for individual files.
Snapshot repository, listing or comparison failures abort with `500` and also
preserve the conversation, so a turn is never dropped while its file changes
stay on disk. Depth and history are validated before any file changes. If the
fork cannot be persisted after files were restored, the response is a `500`
that names the restored snapshot; the original thread still holds the turn and
the `pre-restore:` snapshot holds the previous files.
**`file-revert`.** Request body:
```json
{
"path": "src/lib.rs",
"snapshot_id": "3f2a…40-or-64 hex…",
"expected_hash": "sha256:<64 lowercase hex digits>"
}
```
- `path`: workspace-relative, or absolute inside the thread workspace. The
name is literal (brackets, spaces and glob characters are filename bytes;
Git runs with `--literal-pathspecs`). It must name a regular file: directories,
symlinks anywhere in the path, and `.git` components are `400`.
- `snapshot_id`: the exact `tool` or `pre_turn` restore point of the change
the user selected, from the thread's own turn records: a receipt's
`snapshot_id` or `tree_id` (for a tool call, the receipt whose
`tool_call_id` matches), or the current commit id `GET /v1/snapshots` lists
for it. It must be a restore point recorded on one of this thread's turns;
the server never picks "the newest snapshot that differs", because an
unrelated newer snapshot can erase later user edits while leaving the tool's
change.
- `expected_hash`: `sha256:` of the current file bytes the client displayed,
or `absent` when the client saw the file as deleted. It is checked before the
safety backup and again immediately before the mutation.
Responses:
- `200 {"path", "action", "snapshot_id", "snapshot_label"}` — `action` is
`modified`, `recreated` (file was missing) or `removed` (the snapshot does
not contain the file, so the file the tool created is deleted; its parent
directories are left in place).
- `400`: malformed `snapshot_id`/`expected_hash`, path outside the workspace,
or a path that is not a regular file on either side.
- `404`: unknown thread.
- `409`: active turn in an
overlapping workspace; workspace directory not available; snapshot unknown,
pruned, not recorded on this thread's turns (another thread's or a TUI
session's), or not a restore point (refresh the change record); file already
matches the
snapshot (nothing to revert); or the file changed after the reviewed
`expected_hash` (refresh and review again). Nothing is changed in any of
these cases.
- `422`: missing or mistyped body fields.
- `500`: Git or filesystem failure; a failure after the safety snapshot names
that snapshot so the previous bytes can be recovered with
`POST /v1/snapshots/{id}/restore` or `/restore`.
Capability probe: `GET` on the route returns `405` where the endpoint exists
and `404` on an older engine; clients treat any non-`404` as available and
degrade with an explanation otherwise.
**Compatibility stream** (one-shot, backwards-compatible)
- `POST /v1/stream`
**Tasks** (durable background work)
- `GET /v1/tasks`
- `POST /v1/tasks`
- `GET /v1/tasks/{id}`
- `POST /v1/tasks/{id}/cancel`
**Automations** (scheduled recurring work)
- `GET /v1/automations`
- `POST /v1/automations`
- `GET /v1/automations/{id}`
- `PATCH /v1/automations/{id}`
- `DELETE /v1/automations/{id}`
- `POST /v1/automations/{id}/run`
- `POST /v1/automations/{id}/pause`
- `POST /v1/automations/{id}/resume`
- `GET /v1/automations/{id}/runs?limit=20`
Create and update requests accept an optional `model`. When present, each
scheduled or manually triggered run uses that model; omitting it keeps the
runtime's default task model.
Create and update requests also accept an optional `destination_thread_id`.
When set, each run is delivered into that existing thread as a new turn
instead of starting a new task thread; the turn item carries
`{"input_provenance":"scheduled","task_id":...}` metadata, and the turn uses
the thread's own model and permission posture. If the thread is busy the run
waits for its current turn to finish (bounded by the task wall-time budget,
which pauses while a prompt on the run itself awaits a decision).
A destination that no longer exists or is archived fails the run with a
receipt naming the thread; it is never rerouted to a new thread. An empty
string on `PATCH` clears the destination. Create and update return `404` for
an unknown thread and `409` for an archived one.
**Delayed triggers** (one-shot messages; the HTTP face of `send_later`)
- `GET /v1/triggers?thread_id=&status=&limit=50` — newest first; `thread_id`
filters by destination thread, `status` is one of `pending`,
`dispatching`, `fired`, `canceled`, `failed`
- `POST /v1/triggers` — `{"message", "fire_at" | "delay_minutes",
"thread_id"?, "workspace"?, "owner_session_id"?}`; exactly one of `fire_at`
(RFC 3339, in the future) or `delay_minutes` (>= 1). `owner_session_id`
defaults to `thread_id` and is required without one. Returns `201` with the
`DelayedTriggerRecord`; honours `Idempotency-Key` like `POST
/v1/automations`
- `GET /v1/triggers/{id}`
- `DELETE /v1/triggers/{id}` — cancels a pending trigger; `409` once it has
been admitted
A trigger with `destination_thread_id` fires into that thread exactly as an
automation run does above. `send_later` called from inside a Runtime thread
defaults its destination to that thread, so the reminder comes back to the
same conversation; `thread_id: ""` keeps the old new-task-thread behaviour.
The `automation` tool accepts the same `thread_id`. From a tool, `thread_id`
can name only the calling thread (`"current"` or its id); targeting another
thread is a Runtime API operation.
When an automation or trigger names a destination thread, the Engine
publishes typed events on that thread's stream: `automation.created`,
`automation.updated` (`automation_id`, `name`, `rrule`, `status`,
`next_run_at`), `trigger.scheduled`, `trigger.canceled` and `trigger.fired`
(`trigger_id`, `message`, `fire_at`, `status`, `task_id`). They are
notifications for captions, not model input.
**Automation mutation preconditions.** `GET /v1/runtime/info` advertises
`capabilities.automation_mutation_preconditions: true`; require it before
relying on the behavior below (older Engines ignore both inputs).
- Every `AutomationRecord` carries an integer `revision`: `1` on create, `+1`
on each `PATCH`, `pause` and `resume`; legacy records read as `0`. Scheduler
bookkeeping (`next_run_at`, `last_run_at`) does not change it.
- `expected_revision` is an optional precondition on `PATCH`, `run`, `pause`
and `resume`: a JSON body field on `PATCH`, or the `?expected_revision=N`
query parameter on any of the four. On mismatch nothing is written and the
response is `409` with
`{"error":{"code":"revision_conflict","current_revision":N,...}}`. Omitting
it behaves as before.
- `Idempotency-Key` (1-255 visible ASCII characters) is optional on `POST
/v1/automations`, `PATCH`, `run`, `pause` and `resume`. The first successful
response (2xx) is persisted in `idempotency.json` beside the automation
store, bounded to 1024 entries and a 24 hour TTL. A retry with the same key
and identical request returns the stored status and body with
`Idempotency-Replayed: true` and performs no second create or run. The same
key with a different request is `422` `idempotency_key_reuse`. Failed
attempts are not stored, so they can be retried. Keys are scoped to the
route and automation id. If the record cannot be persisted after the
mutation committed, the success response is still returned (the failure is
logged), so a retry of that request is not deduplicated. An unparseable
`idempotency.json` is renamed to `idempotency.corrupt-.json` and
that request fails with `500`; later requests start from an empty store.
`DELETE` is not covered.
**Operate** (always-on named operation; same `OperateRecord` as CWC
`20de981` / PR #284)
- `GET /v1/operate` — current operation + plan board
- `POST /v1/operate` — create (`direction`, optional `burnRate`)
- `PATCH /v1/operate` — steer direction, `burnRate`, or `leadPlan`
- `PUT /v1/operate/plan` — set `leadPlan` (`{ slices: [...] }`)
- `POST /v1/operate/keepalive` — observe spend / burn; never stops
- `POST /v1/operate/cancel` — explicit cancel (`/v1/operate/stop` aliases);
also pauses the `cw-operate` keepalive so nothing keeps spending after cancel
- `POST /v1/operate/auto-merge/check` — call landed
`scripts/check-auto-merge.py --repo --pr --agent` (does not merge)
The operation record (`current.json`) is persisted under a cross-process
file lock with atomic temp+rename writes; every PATCH / keepalive / plan
save reloads the latest state inside the lock, so concurrent saves merge
instead of losing writes. A `PATCH` that changes `direction` invalidates
the recorded `leadPlan` (workers stop executing superseded slices) and
pulls the keepalive lead run forward to re-plan. `POST /v1/operate`
installs the hourly `cw-operate` keepalive and kicks its first lead-plan
run immediately instead of waiting out the first recurrence; credentials
resolve through the normal Z.ai provider resolution (config, `api_key_env`,
secret store, or provider env vars — blank values count as missing).
`burnRate` is `{ "kind": "usd_per_hour", "amountUsdPerHour": number }`,
a positive number, or `null` (unbounded). Status is
`planning | running | idle_blocked | cancelled`. Pace
(`unbounded | hold | throttle | widen`) is not a status: over-target
throttles, under-target widens, no wallet-cap stop. Idle-blocked only
for empty direction, awaiting lead plan, missing credentials, or a
human gate. Auto-merge is `scripts/check-auto-merge.py --repo … --pr …
--agent …` from codewhale-ops `origin/main` (exit 0), then
`scripts/auto-merge-pr.py`. Do not invent a second checker.
**Workspace trust** (the persisted project decision)
- `GET /v1/workspace/status` reports `workspace_trusted`: whether the server’s workspace is trusted in the discovered user config. Project skills, commands and MCP configuration consult this decision. Project hooks require both workspace trust and separate approval of the exact hooks file.
- `POST /v1/workspace/trust` with `{"trusted": true|false}` records that decision and answers `{workspace, workspace_trusted, config_path, message}`.
The handler reuses `config::set_workspace_trust`, the writer behind `/trust on|off --save`, and reads the decision back through `is_workspace_trusted`. The request accepts no path; it applies only to the server’s admitted workspace. `config_path` identifies the document written. Both read and write use default config discovery, including configured environment overrides; a runtime’s explicit `--config` argument does not select this trust document.
Project skills and commands consult trust at their existing load or turn boundaries. Project hooks also need their separate digest approval and recheck authority before execution. Restart the engine to rebuild project MCP connections; this request does not disconnect an existing pool or interrupt a running turn. Project instruction files such as `AGENTS.md` and project rules are loaded by the existing context loader independently of this flag, so revoking workspace trust does not remove those instructions or their persisted history.
This persisted project decision is separate from a thread’s `trust_mode` / Full Access and from `/trust add ` external-path grants. `/trust on|off --save` changes session file-tool trust and the persisted project record. This HTTP route changes only the persisted project record. Its POST requires the same Runtime write authority as other state-changing routes; watch device tokens can read status but cannot write trust.
**Introspection**
- `GET /v1/workspace/status`
- `GET /v1/workspace/files/search?query=&limit=<1-100>` (see workspace file suggestions above)
- `GET /v1/workspace/files?path=&limit=<1-2000>`, `GET /v1/workspace/files/read?path=&offset=&limit=`
and `PUT /v1/workspace/files` (see workspace files and session artifacts above)
- `GET /v1/skills`
- `GET /v1/skills/{name}` — one skill's routing metadata (`source`,
`invocation`, `aliases`, `bundled_tier`, `enabled`) plus its full
`SKILL.md` body, so a client can compose an activation instruction for its
own next turn the way TUI's `/skill ` does. `404` for a name no
discovery root holds; `403` for a plugin snapshot whose authority is no
longer current; native rows whose file has since been deleted also `404`
rather than serving the stale body. Advertised as
`capabilities.skill_detail` on `GET /v1/runtime/info`, which is the source a
client should use rather than probing this path: a `404` here means "no such
skill" and is indistinguishable from "no such route".
- `GET /v1/apps/mcp/servers` — each row reports `connected` and
`has_connection_backoff`; while a server is cooling down after a failed
connect it also carries `last_error`, that attempt's diagnosis (at most 2048
characters). `last_error` is omitted when the server is ready or connecting,
when its configuration changed since the failure, and when its reviewed
plugin authority no longer holds.
- `GET /v1/apps/mcp/tools?server=`
Skill activation toggles are persisted under a cross-process transaction lock.
Each mutation reloads and merges the latest exact-name state before an atomic
write, and `GET /v1/skills` refreshes that shared state so another Codewhale
process's successful toggle is visible without restarting the Runtime API.
Skill rows on `GET /v1/skills` carry `invocation`, `aliases`, and
`bundled_tier` alongside the fields they always carried, so a client can build
a picker, autocomplete, or activation gate without a second request per row.
**Desktop side browser.** A desktop host registers its side-browser MCP server
with `POST /v1/apps/mcp/desktop-browser` (`{url, bearer, signature}`, signed by
the host key the Engine was launched with). Agent calls to that server are
fenced by the Codewhale account that admitted the turn. When a person's turn
starts, the Engine records who is signed in (the account and its login
session, or signed out). A later `codewhale-browser` tool call from that turn,
its sub-agents or its programs is refused with a permission error saying
nothing was sent once the signed-in account differs: after a sign-out, a
sign-in, a replaced account or a new login session. A turn whose account could
not be read is refused the same way; token refresh does not trip the fence.
Known limits of this interim fence: the Engine sends the browser server no
caller identity, registrations carry no generation (a delayed older
registration can still replace a newer one), account states the desktop keeps
to itself (development sessions, replay, an unreachable account service) are
not observed, and a Platform-side revocation counts only once a refresh writes
it to the local account store.
**Usage** (token/cost aggregation across threads)
- `GET /v1/usage?since=&until=&group_by=`
`since` / `until` are inclusive RFC 3339 timestamps and may be omitted (no
bound). `group_by` defaults to `day`. Buckets are sorted by ascending key.
Empty time ranges produce empty `buckets` (never a 404). Cost is computed via
the model→pricing map; turns whose model has no pricing entry contribute
tokens but `0.0` cost. Added in v0.8.10 (#564).
```json
{
"since": "2026-04-01T00:00:00Z",
"until": "2026-04-30T23:59:59Z",
"group_by": "day",
"totals": {
"input_tokens": 12345,
"output_tokens": 6789,
"cached_tokens": 0,
"reasoning_tokens": 0,
"cost_usd": 0.012,
"turns": 42
},
"buckets": [
{
"key": "2026-04-30",
"input_tokens": 1234,
"output_tokens": 678,
"cached_tokens": 0,
"reasoning_tokens": 0,
"cost_usd": 0.001,
"turns": 3
}
]
}
```
### Native client routes
These families serve native clients over the same bearer-token
transport. They reuse the runtime's existing authorities — the engine's shell
manager, the durable thread store, the workspace confinement layer, the
config's credential plumbing — and add no second runtime, session store,
scheduler, or credential store.
**Terminal sessions** (the persistent Engine-owned shell)
The jobs family above runs one command per job. A terminal pane needs the
*other* authority: the stateful PTY-backed shell the agent's own terminal
tools drive, which keeps cwd and environment across inputs. Each shell belongs
to the thread whose turn started it, in that thread's workspace: two
conversations that both use `term-1` in one folder get two shells. These
routes attach to those shells and never create one, because conjuring a shell
from an HTTP request would give the client a terminal the Engine does not
know about. Input is attributable by route: `input` is the client's writer,
`terminal/send` is the agent's.
- `GET /v1/threads/{id}/terminals` → `{thread_id, workspace, instance,
terminals: [{terminal_id, name, state, running, exit_code, updated_at}]}` —
the thread's live shells, newest activity first. `terminal_id` is issued
when the shell starts and is never reused; the agent's terminal tool results
carry the same value as `terminal_session_id`, and `terminal/reset` also
reports `previous_session_id`. `instance` names this Engine process's
terminal registry. While a shell lives, `state` is its latest command's
state (`running`, `idle`, `canceled`, `failed`, `stale_lost` or `reset`);
once the shell itself has ended it is `exited`, `running` is false and
`exit_code` holds the shell's status
- `GET /v1/threads/{id}/terminals/{terminal_id}/output?cursor=&
max_bytes=<1-64KiB>&format=` — the resumable byte stream.
`{terminal_id, offset, next_cursor, total, dropped, gap, encoding, data,
running, exit_code}`: pass `next_cursor` back to continue; reads never
consume, so several clients may hold independent cursors; `gap` is true and
`offset` exceeds `cursor` when the 512 KiB ring already discarded the bytes
asked for (`dropped` counts them), and a cursor past `total` is answered from
`total` rather than echoed back
- `POST /v1/threads/{id}/terminals/{terminal_id}/input` — `{ "data",
"encoding"? }`, `base64` by default (exact bytes) or `text` for UTF-8 →
`{ "terminal_id", "written" }`. It needs the thread's shell consent (`403`
`thread_shell_disabled`) and the loaded shell policy (`403`). When the
thread's current posture requires a sandbox, a shell that was not started
under exactly that policy refuses input (`403` `terminal_posture_changed`);
an exited shell refuses it too (`409` `terminal_exited`). A lone ETX
(Ctrl-C) while the agent's command runs takes the confirmed-interrupt path,
so the agent's wait sees exit status 130; if the shell does not confirm it
within 2 seconds the call answers `500` saying the interrupt was sent, and
the client must not send it again on its own
- `POST /v1/threads/{id}/terminals/{terminal_id}/resize` — `{ "rows", "cols" }`
→ the kernel window the child draws for
- `POST /v1/threads/{id}/terminals/{terminal_id}/kill` — end the shell →
`{ "terminal_id", "killed" }`, with `killed: false` when it had already
exited; observe the exit through `output` (`running` / `exit_code`) rather
than the acknowledgement
Every call resolves the thread's recorded workspace again. An unknown thread
is `404`; a workspace that is missing, replaced by a link or changed during
resolution is `409` `thread_workspace_unavailable`; and an identity that is not
one of this thread's live shells in that workspace (another thread's, another
workspace's, a reset shell's, or one from before an Engine restart) is `404`
`terminal_not_found`. The Engine never substitutes a shell with the same name.
Reconnect and restart semantics:
- **Reconnect** (a dropped connection, a reopened pane or window, or a client
app restart while the Engine kept running): list the thread's terminals,
confirm `instance` is the one recorded, and resume each `terminal_id` from
its stored cursor. Only retained output is replayed; `gap` reports what the
ring lost.
- **Reset** (`terminal/reset`): the shell is killed and a new one starts under
the same name with a new `terminal_id`. The old identity is `404`; the list
shows the replacement, and a client switches to it only on the person's
choice.
- **Exit**: the identity stays listed with `running: false` and its
`exit_code` until the agent resets it or the Engine restarts.
- **Engine restart**: live shells end with the process. The list reports a
different `instance` and none of the old identities, which answer `404`. No
shell is recreated and no command is rerun; a later `terminal/run` with the
same name starts a new identity.
- **Input across a failure**: input already sent cannot be retracted. A client
that loses the connection before the response cannot know whether the bytes
reached the shell and must not resend them automatically.
`GET /v1/runtime/info` advertises `thread_scoped_terminals`. It is `false` on
Windows and OpenHarmony builds today: the owner is Unix-only, those routes
answer `501`, and a client should gate its terminal controls on the flag
rather than discovering it from a failed request. The name-addressed routes
`/v1/terminal/{name}/*` and their `terminal_stream`, `terminal_input`,
`terminal_resize` and `terminal_kill` flags were retired in 0.10.3: a name at
the server's workspace could reach another thread's shell, so those paths now
answer `410` `terminal_route_retired`. Known limitations, stated because a
reader would otherwise assume them: there is no `wait_ms` long poll (poll the
cursor), scrollback dropped by the ring is gone with the process, shells
started outside a runtime thread (the TUI dock, headless runs) have no route
here, deleting a thread does not end its shells (they run until the Engine
exits), and the `@codewhale/runtime-sdk` package has no terminal client
wrapper yet — the raw routes are the contract for now.
**Jobs** (operator-scoped shell jobs; the terminal surface)
- `GET /v1/jobs` — every live and known-stale job across all threads
- `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager:
model-launched, subagent-launched, and client-launched together
- `POST /v1/threads/{id}/jobs` — `{ "command", "cwd"?, "timeout_ms"?,
"tty"?, "env"? }` → `201 { "job" }`; runs as a background shell under the
thread's projected sandbox policy. `tty: true` merges stderr into stdout
and gives the command a terminal (required for interactive programs);
background jobs are never killed at `timeout_ms`. A relative `cwd`
resolves against the thread workspace. Outside trust mode `cwd` must stay
inside it after symlinks resolve (`403` otherwise): unlike the shell tool,
this route does not follow `workspace_follow_symlinks` or `/trust add`
roots, so a symlink leading out of the workspace is refused. The job runs
in the resolved directory that was checked (a later symlink retarget does
not move it); a `cwd` that resolves to a non-UTF-8 path is a `400`
- `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata
- `GET /v1/threads/{id}/jobs/{job_id}/output?stream=&cursor=
&max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=` — the
resumable byte stream. `{job_id, stream, offset, next_cursor, total,
dropped, encoding, data, status, exit_code, done}`: pass `next_cursor`
back to continue; `wait_ms` long-polls for new bytes on a running job;
`done` means a terminal status and nothing left past the cursor
- `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ "data", "encoding"?,
"close"? }`: `data` is UTF-8 text by default or `base64`, `close: true`
sends EOF; works for PTY and piped jobs → `204`
- `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL
escalation on the process group → `{ "job", "result" }` with the final
snapshot
Reads are non-consuming: several clients may hold independent cursors, and
polling never steals output from the engine's own delta consumer. The
buffer is bounded with exact drop accounting — a reader whose `cursor`
falls behind the retained window gets `offset` past it and `dropped > 0`,
and must re-anchor. Evicted jobs keep a tail snapshot which the output
route serves as the final retained window. Jobs are scoped to the thread
that created them and are killed when that thread is removed; the engine's
background commands use the same per-thread manager, so `GET /v1/jobs` is
also how a client sees model-spawned work.
**Durable client job operations** (capability `job_operations`)
Authorized `GET /v1/runtime/info` supplies `job_operation_store_id`, the
persisted runtime store identity. It survives Engine updates and restarts;
it is a namespace, not a credential. Unauthorized discovery returns null.
Keyed job creation adds all four fields to the existing POST body:
`operation_key` (a fresh UUID v4 retained before submission), `purpose`
(`setup`, `run`, `archive` or `command`), `expected_store_id`, and
`expected_workspace`. The store must match the current store, and the workspace
assertion must match the admitted thread workspace; otherwise dispatch refuses
`409 job_operation_store_changed` or `409 job_operation_workspace_changed`.
These assertions never move a thread. Omitting all four retains the legacy
`201 {job}` contract.
A new keyed request returns `201 {job, operation, request_fingerprint}`. The same key and exact
normalized request return `200` with the existing operation and never spawn
again, including after restart or a lost response. Changing the bound request
returns `409 job_operation_mismatch`. `job` may be null when the durable
receipt survives without its live handle or retained output. The operation is:
```json
{
"id": "opaque-receipt-id",
"store_id": "runtime-store-id",
"thread_id": "thread-id",
"workspace": "/canonical/workspace",
"cwd": "/canonical/launch-directory",
"purpose": "run",
"status": "running",
"job_id": "shell-job-id",
"job_status": null,
"exit_code": null,
"created_at_ms": 1794000000000,
"updated_at_ms": 1794000000000
}
```
`status` is `running`, `completed`, `rejected` or `unknown`. A completed receipt has a
terminal `job_status` (`Completed`, `Failed`, `Killed` or `TimedOut`) and a
nullable exit code; success requires `Completed` and exit code zero. Timestamps
are Unix milliseconds; `updated_at_ms` is the last persisted transition, not
proof of when an unknown outcome occurred. The opaque receipt ID does not
expose the original operation key.
`request_fingerprint` binds the original request, including its requested
workspace spelling; the operation's workspace and cwd remain canonical. It is
lowercase HMAC-SHA256, keyed by the operation UUID, over UTF-8 JSON for
`[2, thread_id, expected_workspace, trimmed_command, cwd_or_null, timeout_ms,
tty, sorted_environment_pairs, purpose]`. Command trimming uses Unicode
White_Space; environment pairs sort by UTF-8 key bytes. Omitted timeout is
120000, omitted cwd is null and omitted tty is false. Persist the expected
fingerprint before POST, and compare it on response and exact-key GET recovery
before accepting a receipt or clearing a pending intent. An alias is not a
reason to compare the canonical receipt path literally with the requested
spelling. A missing or mismatched binding stays unconfirmed; do not re-POST
automatically. The UUID and fingerprint do not provide encryption or protect
low-entropy request values from someone who obtains both.
A `rejected` receipt proves the command never started: its job fields are all
null. The shell executor supplies this typed witness only for audited failures
before launch, such as PTY allocation or a working-directory check. It is a
terminal tombstone, so the same key returns it without retrying; a new key can
try again after the problem is fixed. The POST refusal uses `409
job_operation_rejected`. Spawn-wrapper and post-launch failures remain unknown
unless a trustworthy witness proves otherwise.
- `GET /v1/job-operations` and `GET /v1/threads/{id}/job-operations` return
`{operations: [...]}`, including records whose workspace folder is gone.
- `GET /v1/threads/{id}/job-operations/{operation_key}` returns the same
`{job, operation, request_fingerprint}` envelope. A storage or admission error is not an empty
list or proof of absence.
- Contended admission returns `409 job_operation_pending`. A new keyed
setup/run/archive overlapping an active keyed workspace script returns
`409 job_operation_busy`. An overlapping unconfirmed operation refuses
every new job, including legacy requests, with `409
job_operation_unconfirmed`; worktree removal is fenced as well.
The existing runtime thread store atomically records intent before spawning;
the existing shell manager remains the execution authority. A completion
observer and reads persist verified terminal parent status without consuming
output. Durable records contain a keyed request fingerprint rather than raw
commands, environment values or operation keys. A missing live handle,
different owner generation or crash around dispatch remains `unknown`, never
permission to repeat the command. New job IDs use full UUIDs; clients must
treat them as opaque. This is not an exactly-once OS transaction, durable
output capture, automatic unknown-outcome recovery or a phased archive
transaction. Detached descendants, agent/tool launches and other stores remain
outside this API operation fence.
Admission resolves one canonical workspace before project shell policy checks
and uses it for policy and cwd resolution. It rechecks the requested root and
launch directory before recording intent. These pathname checks do not provide
a directory-handle guarantee against replacement after the final check.
Thread deletion and empty-thread discard share job admission and refuse a
thread whose durable client job operation is running or unconfirmed. Stop the
known job and wait for its terminal receipt before deleting the conversation;
an unknown outcome requires recovery rather than silently dropping the fence.
Completed and rejected receipts remain in the global operation list after
thread deletion.
**Commands** (typed command catalog, APPS-28)
- `GET /v1/commands` — `{commands: [...]}`: every registered slash command,
builtin and user, as the TUI's own registry holds it. Per entry: `name`,
`aliases`, `summary` and `usage` (English source text — localizing is the
client's surface), `subcommands` (the literal verbs the usage line
declares), `takes_arguments`, `kind` (`builtin` registered code, or `user`
expanding a stored template), `binding` (`host` runs locally and never
reaches the model; `prompt` expands into the request the model sees),
`discovery` (`primary` / `advanced` / `compatibility`, builtins only),
`hidden` for rows the product does not advertise, and `shadowed_by` /
`shadowed_aliases` where a user command has taken a builtin's spelling.
Each entry also carries the composer argument shape, computed the way the
TUI composer computes it, so a client does not re-derive it from `usage`
(#6230):
- `requires_argument` — the usage line mentions any argument, required or
optional.
- `requires_required_argument` — the usage line has a `` argument
outside every `[optional]` group.
- `composer_wants_trailing_space` — accepting the command leaves a trailing
space for its arguments.
- `palette_runs_directly` — the palette runs the command on selection
instead of pasting it into the composer.
- `show_in_empty_discovery` — the command is listed when the slash menu
opens with no filter text.
User commands derive these from `takes_arguments`: their arguments are
never required, a template that takes arguments waits in the composer and
one that does not runs directly, and a `hidden` template stays out of empty
discovery.
The same registry the TUI palette reads, so a desktop palette can be
checked against it instead of drifting from it. Two rules a client must
respect: a `binding: "host"` row is never submitted as a model prompt, and
a user command shadowing a builtin name wins that spelling.
- `POST /v1/threads/{id}/commands/builtin` — run one terminal built-in
against the thread and return its own answer: `{name, text, is_error,
text_only}`. The body is `{name, args?}`, where `name` takes the canonical
spelling or an alias, with or without the leading slash.
Only read-only diagnostics the server can answer from thread state are
served (`/system`, `/tokens`, `/cost`); the served set lives in the
terminal host and is gated a second time by the data each portable handler
declares it needs, so a route cannot widen without the host widening
first. Any other name — including one no host knows — answers 404. A
thread whose engine is not loaded, or that has no live context estimate
yet, answers 409 rather than printing zeros; the route never starts an
engine. `/system` reports the effective system prompt the engine sends, and
`/tokens` counts the live session transcript. Like every other POST route,
it answers 403 to a watch-only device token, so a watch client cannot read
`/tokens` or `/cost` through it.
`text_only` is `false` when the command also asked the terminal host for an
action (a balance fetch, a snapshot restore): the text is still what the
command answered, but no client can perform that action on the host's
behalf. These executions keep no command receipt — every served handler is
pure over the snapshot — so a client must not look for one. Anything that
mutates the session stays a terminal-side command.
**Hooks**
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
problems: [...]}`: the hook set Runtime API threads run for that workspace
(the server workspace, or the named thread's). Per entry: `name`, `event`,
`command` (credential-shaped values masked; every URL keeps only its scheme and host), `background`, `timeout_secs`,
and `source` (`global` user config, `plugin` reviewed plugin, `project`
trusted and approved `.codewhale/hooks.toml`). `problems` lists hooks
rejected or warned about at load, one line each.
Every Runtime thread builds its engine with this set: `tool_call_before`
can deny a call, `shell_env` applies to shell tools, and `tool_call_after`
and `on_error` (for a failed tool) fire as observers, as they do in the TUI.
Clients read this route instead of keeping a hook table of their own.
**Context** (per-thread context pressure, APPS-90)
- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live
estimate the visible meter uses), `billed_input_tokens` (last
provider-counted prompt size when one exists), `window_tokens`,
`output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`,
`compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the
live engine via `Op::GetContextBudget`. Every numeric field is nullable —
a route that cannot express a bounded window reports `null` rather than an
invented number — and `live: false` marks responses where the engine
could not be loaded and only the store-recorded route's static window
resolved.
**Thread-scoped reads.** `GET /v1/git`, `/v1/changes`, `/v1/diff`,
`/v1/workspace/diff`, `/v1/git/graph`, `/v1/workspace/files` and
`/v1/workspace/files/read` accept an optional `thread_id`. With it, the route
reads the workspace recorded on that thread (the runtime store's thread record
is the only source) instead of the server's workspace, through the same
confined paths. Without it the route behaves exactly as before. An unknown
thread is 404; a recorded workspace that is not an absolute path, or that is
missing, replaced by a link or changed during resolution is 409 with
`error.code` `thread_workspace_unavailable`. Mutating git routes, file
writes, `/v1/workspace/status` and file search stay on the server workspace.
Clients must require the `thread_scoped_reads` capability on
`GET /v1/runtime/info` before sending `thread_id`: older Engines reject the
unknown query parameter.
**Status unavailable.** `GET /v1/workspace/status` and `GET /v1/git` report
`status_unavailable: true` when `git status` could not be read (git failed to
start or exited non-zero, or the repository's filter configuration could not be
inspected). `dirty` and the change counts are then unknown, not zero, and clients
must not present them as clean. Status reads neutralize clean/process filter
drivers defined in the repository's own configuration (local or worktree scope);
drivers defined only in the user's global or system configuration, such as Git
LFS, keep working.
**Git** (workspace repository operations, APPS-106)
- `GET /v1/git` — status detail: `git_repo`, `branch`, `head`
(abbreviated, for display), `head_oid`, `index_token`, `revision`,
`ahead`/`behind`, counts, per-file porcelain `files[]`
(`{path, index, worktree, staged, status, old_path?, rev}`), `branches`,
`remotes`. `files[].path` and `old_path` are workspace-relative, the same
frame the write routes take; in a workspace that is a subdirectory of its
repository, rows outside the workspace are not listed (the counts stay
repository-wide). A rename out of the workspace appears as its source
deletion. Normal Git filters and untracked settings apply. Untracked
directories stay collapsed; only a row naming the workspace itself is
expanded to individually addressable files. The precondition tokens are
opaque:
- `head_oid` — the full HEAD commit id; `null` on an unborn branch or when
HEAD cannot be read
- `index_token` — the whole index (mode, blob, stage and path of every
entry, repository-wide). A stat-only refresh by `git status` does not
change it. Status remains best-effort on repository read failures;
unavailable tokens are `null`, and broken HEADs cannot satisfy guards
- `files[].rev` — one row: its index entries plus the working-tree state
of every file it covers (including a rename's in-workspace source).
Content tokens (`c-…`) include file bytes, the executable bit on Unix,
symlink targets, and submodule HEAD/status. Submodule dirty contents
are summarized by porcelain status, not recursively hashed; ordinary
stage/discard do not write those contents. A read hashes at most
64 MiB / 4,096 files; rows past that budget or containing a file over
16 MiB carry a display-only size-and-mtime token (`s-…`). Those tokens
cannot guard writes. Unreadable paths, paths beneath symlinked
directories, special files and broken nested repositories have `rev: null`;
other rows retain their tokens. If a broken tracked submodule aborts
porcelain, ordinary rows are recovered without submodule recursion and
the unreadable submodule is shown with `status: "unknown"`, `worktree: "?"`
- `revision` — the whole tree: `head_oid`, `index_token`, every row's
`rev`, including the working-tree state of rows outside a subdirectory
workspace. `null` when any row is stat-only or unreadable, a repository
read is incomplete, or untracked paths are hidden; whole-tree guarded
writes are unavailable in that case. Clients must not silently omit a guard
- `GET /v1/changes` — the same porcelain `files[]` projection plus
`head_oid`, `index_token` and `revision`, minus repo chrome
(branches/remotes): one authority, so the change list can never disagree
with the status read
- `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`,
or the empty tree on an unborn branch — which reads staged adds as new
files). Covers staged+unstaged in one patch; `truncated` reports the
512 KiB cap. An untracked file answers `untracked: true` with an empty
diff — the client reads the file itself rather than mistaking it for
unchanged
- `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB,
max 4 MiB) plus a complete `--numstat` `files[]` inventory
(`{path, added, deleted}`) so every changed row renders even when the
patch is truncated
- `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`,
`parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is
an empty graph, not an error
- `POST /v1/git/stage` `{ "paths": [...] }` or `{ "all": true }`;
`POST /v1/git/unstage` same; `POST /v1/git/discard` `{ "paths": [...] }`
(tracked paths only — no `all`, an untracked path fails closed);
`POST /v1/git/commit` `{ "message", "all"? }`; stage, unstage, discard
and commit also take an optional `expect` (below). Stage, unstage and
discard also take `patch`: hunks of the one path in `paths`, with file
headers `diff --git a/ b/`, `--- a/`, `+++ b/`
naming that workspace-relative path. Stage applies them to the index,
unstage reverses them in the index, discard reverses them in the working
tree (`git apply [--cached] [--reverse]`). Renames, copies, mode changes,
creations, deletions and binary patches are refused, and a patch that no
longer applies fails with git's message and writes nothing. `POST /v1/git/push`
`{ "branch"?, "expect"?: { "head" }, "remote"?, "set_upstream"? }` pushes
exactly one branch at one commit (*Push* below);
`POST /v1/git/branch`
`{ "name", "create"? }`
- Every write above also takes `?thread_id=` (capability
`thread_scoped_git_writes`) and then runs in that thread's recorded
workspace instead of the server's, the same scope the read routes use. A
thread whose workspace is missing or changed answers `409`
`thread_workspace_unavailable`.
- `GET /v1/worktrees?thread_id=` — `{repository, worktrees[]}` with
`{path, branch, head, main, managed, locked, prunable}` from
`git worktree list --porcelain`; `managed` marks the ones under
`/.codewhale/worktrees/`. Capability `worktrees`.
- `POST /v1/worktrees?thread_id=` `{ "branch", "base"? }` — validates the
branch with `check-ref-format`, refuses a branch already checked out
elsewhere (`409 branch_checked_out`) or an existing folder
(`409 worktree_exists`), adds `/.codewhale/worktrees/` to the repository's
`info/exclude` so the main checkout stays clean, and runs
`git worktree add -b -- /.codewhale/worktrees/ `
(`base` defaults to the main checkout's `HEAD`). Answers `201`
`{path, branch, base, head, repository}`. A client then creates a thread
with `workspace: path`.
- `POST /v1/worktrees/remove?thread_id=` `{ "path" }` — archives a managed
worktree: refuses unmanaged or main worktrees (`403`), a locked one
(`409 worktree_locked`), active turns in it (`409 worktree_busy`) and
uncommitted or untracked changes (`409 worktree_dirty` with `files[]`),
then runs `git worktree remove` without `--force`. The branch and its
commits stay. A worktree whose folder is already gone is removed with `--force` for that exact path; other prunable worktrees are untouched.
Worktree removal also refuses `409 worktree_busy` while this runtime tracks a
running shell job whose launch directory overlaps the worktree. Removal and
API job starts share admission with new turns, and retain their guards until
the mutation finishes even if the HTTP request disconnects. This covers the
same runtime manager's tracked jobs. Durable client job operations additionally
fence unconfirmed overlapping dispatches in their shared store, including
after Engine restart. Launch directories do not prove where a
command later changes directory; escaped descendants, late independent
subagent launches and other runtime managers or processes remain outside this
guard. It is not an OS-wide process reservation.
Diffs and precondition token reads run through the hardened review command
(filters, fsmonitor, hooks, lazy fetches and replace-objects neutralized).
Porcelain status uses normal Git, matching the workspace counts and filters;
writes run through the non-interactive command path
(`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a credential or host-key prompt
can never hang a request. Path lists are workspace-relative under the same
confinement as the file routes (traversal → 400, `.git` → 403), passed after
`--` under `--literal-pathspecs`, so `src/*` names a file called `*` and is
never a glob. (Unstage of the whole tree uses the `:/` root pathspec.)
Mutations answer `{ok, output, status, current}`: the refreshed status and
the full `GET /v1/git` detail, so a client re-reads nothing after an
operation and can chain the next write from fresh tokens. A workspace that
is not a repository answers `404`.
*Preconditions.* Stage, unstage, discard and commit accept
`expect: { head?, index?, revision?, files? }`, built from the last
`GET /v1/git`. Each present field is checked and an absent one is not;
`head: null` means "HEAD must still be unborn". Without `expect` (or with
`expect: {}`) a write behaves exactly as before. Recommended use:
| Operation | `expect` |
| --- | --- |
| stage / unstage `paths` | `{head, files: {path: rev}}` |
| stage / unstage `all` | `{head, revision}` |
| discard (always — it destroys edits) | `{head, files: {path: rev}}` |
| commit | `{head, index}` |
| commit `all` | `{head, revision}` |
Malformed preconditions answer `400` before anything runs: `head` must be a
40- or 64-hex id or `null`; `index` and `revision` 64 hex (an explicit
`revision: null` is refused); `files` values a content-safe `c-` rev (`s-`
tokens answer `400`). `files` keys
(workspace-relative; `dir/` and `dir` are the same key)
must name exactly the requested paths, so no path is left unguarded by
accident. `files` is refused with `all: true` (use `revision`) and on
commit. An unknown key inside `expect` is rejected like any other unknown
field. When the repository no longer matches, the route writes nothing and
answers `409`:
```json
{ "error": { "message": "The repository changed since it was read (HEAD moved; src/a.rs changed). Nothing was written; refresh and review again.",
"status": 409, "code": "git_state_changed" },
"stale": ["head", "files"], "stale_paths": ["src/a.rs"],
"current": { "...": "the GET /v1/git detail" } }
```
`stale` lists the components that moved (`head`, `index`, `files`,
`revision`); `stale_paths` the `files` keys whose `rev` changed. A client
re-renders from `current`, keeps the user's selection, and asks again.
*Push.* `GET /v1/git` advertises `named_push: true` when the route accepts
`branch` and `expect.head`. A client sends the `branch` and `head_oid` it
read: `{ "branch": "feature", "expect": { "head": "" } }`. The
branch must still be checked out and its tip must still be that commit;
otherwise the route pushes nothing and answers `409 git_state_changed` with
`stale` naming `branch` (another branch, or a detached HEAD) and/or `head`,
plus `current`. `branch` without `expect.head`, `expect` without `branch`,
`head: null`, or any other `expect` field answers `400`. An Engine without
`named_push` rejects the unknown `branch` field with `400` and pushes nothing,
so a new client fails closed against an old Engine.
Every push, named or not, runs
`git push --no-follow-tags --no-recurse-submodules -- :refs/heads/`:
one source commit and one same-named destination branch, never a force push.
`push.default`, `remote..push`, `push.followTags` and submodule
recursion cannot add refs, and `remote..mirror` makes git refuse
(`400`). A request without `branch` pushes the checked-out branch at its
current tip; from a detached HEAD or an unborn branch it answers `400`.
The remote is `remote`, else `branch..pushRemote`, `remote.pushDefault`,
`branch..remote`, then `origin`, and must be a configured remote name.
A branch whose upstream on that remote is a differently named branch answers
`400`, as git's default `simple` mode does. `set_upstream: true` records
`branch..remote` and `branch..merge` after the push succeeds. Not
covered: the remote's own hooks and `pushurl`, and whatever credential
helper the user configured, still apply as in the user's terminal.
Stage, unstage, discard, commit and branch from one runtime are serialized,
so a check and its write are atomic with respect to that runtime's other
windows; a second concurrent write answers `409` with
`error.code: "git_busy"` rather than queueing behind a long commit hook.
Push is not serialized: it only moves a remote ref and can wait on the
network for up to 120 s. The lock does not cover processes outside this
runtime — a terminal, an editor, or Codewhale's own agent tools — which can
still change the repository in the moment between the check and git taking
`index.lock`; a truly concurrent git write then fails on git's own
`index.lock` (a `400` carrying git's message). A compare-and-swap commit via
`commit-tree` and `update-ref` would close that window but skip the
repository's hooks, which a Review-sheet commit must run, so it is not used.
**Diagnostics** (read-only logs, crashes, process — APPS-103)
- `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` —
the runtime's log directory plus `audit.log[.1]` from the codewhale home,
newest first, capped
- `GET /v1/logs/{name}?offset=&limit=&tail=` →
`{name, size, modified, offset, bytes, truncated, encoding, content}` —
one bounded window; `tail` reads from the end and is mutually exclusive
with `offset`; `truncated` means bytes remain after the returned window
(a tail read at EOF is `false`), `encoding` is `utf-8` or `base64`
- `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract
over the crash-dump directories (`~/.codewhale/crashes`, legacy
`~/.deepseek/crashes` merged)
- `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds,
executable, rss_bytes}` — `rss_bytes` only where the platform reports it
(Linux `/proc`); absent rather than fabricated elsewhere
These routes package what already exists on disk for a client-side export;
there is no telemetry upload route and no second log store. Names are
basename-validated (no separators, no `..`), listings are capped, reads are
bounded windows, and symlinks are never followed — a client bundles the
files itself.
**Targets and remote posture** (APPS-50)
- `GET /v1/targets` → `{targets: [self], remote: {supported: true,
attach: "client", probe: "POST /v1/remote/connect"}, ssh: {…},
cloud: {…}}` — this runtime's own record as the attachable target plus
per-surface ownership; the runtime keeps no persistent target registry,
so `POST /v1/targets` and `POST /v1/targets/switch` answer
`501 Not Implemented` — target selection is client-owned and a switch
must never move a running task server-side
- `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan,
auth_required, mobile, tls}` — this listener's reachability posture.
`tls` is always `false`: the API has no TLS terminator, so non-loopback
reachability assumes a verified overlay (VPN/mesh), never plain LAN trust
- `POST /v1/remote/connect` `{ "endpoint": "http://host:port" }` — probes a
candidate remote's unauthenticated `GET /v1/runtime/info` (origin only;
any pasted path is discarded). Answers `{ok, remote: {endpoint,
runtime_api_version, codewhale_version, auth_required, …}, attach:
"client"}` on success, and `{ok: false, reason: "unreachable" |
"not a Codewhale runtime" | …}` as data on failure. URLs carrying
credentials are refused with 400 — the remote's token is configured
client-side, and a connect route that forwarded one would be an
exfiltration primitive
- `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner:
"codewhale-control-plane", reason}`; `POST /v1/ssh/connect` and
`POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted
cloud computers belong to the Apps control plane (ASCII Box for Managed
Computer), not to a second authority inside Core
A remote Codewhale is a `serve --http` runtime with a token — that is the
whole attach model. These routes describe and probe it; they never execute
a remote request on the local machine.
**LSP** (workspace language intelligence, APPS-93)
- `GET /v1/lsp` — capability: `enabled`, supported `languages` with their
server commands, `custom_languages`, operations, poll and diagnostic caps
- `GET /v1/diagnostics?path=` — file diagnostics
- `GET /v1/definition?path=&line=&character=` (1-based)
- `GET /v1/references?path=&line=&character=` (1-based)
- `GET /v1/symbols?path=&query=` — empty query returns document symbols
One lazily-built workspace-level `LspManager` serves these; engine threads
keep their own per-thread managers for the post-edit hook, and a server that
never serves an LSP route never spawns a language server. `path` is
workspace-relative under the same confinement as the file routes. Normal
absence is data: no language server, a disabled `[lsp]` config, or a timeout
answers `200` with `ok: false` and a machine-readable `reason`
(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a
missing file a 404.
**Voice** (host dictation, APPS-98)
- `GET /v1/voice` — capability: `available`, detected `recorder` command,
resolved `asr` `{kind, model}`, `modes`, `send_phrases`,
`max_record_seconds`
- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }`
- `POST /v1/voice/send` — same capture with the "send it" / 发送/發送
suffix contract: `send: true` tells the client to submit (empty `text`
with `send: true` means submit the client's current draft)
- `POST /v1/voice/control` `{ "composer": "draft text" }` — assisted
dictation that shows the model the composer text; `assisted: false` in the
response means a free ASR backend (local whisper/Groq) handled the audio
and the composer context was never seen
The runtime owns the host microphone and the ASR dispatch — the same
implementation the TUI's `/voice` commands run, headless. Recording is one
blocking capture per host (requests serialize; the loser gets
`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its
key lazily so local-whisper and Groq paths work without provider auth.
Interim and final transcription use the selected ASR backend. If local whisper
or Groq fails, the error stays on that backend: the runtime never retries the
recording or composer text with the active model provider. Select provider ASR
explicitly to use that route.
Failure is data: `no_recorder`, `no_speech`, `no_provider_auth`,
`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator
kill-switch — a headless `serve --http` host reports `available: false` and
every dictate call fails closed.
## Provider and model selection
These three routes are how a GUI renders a model picker whose contents are true
for *this* runtime instead of guessed from a version snapshot. They were
undocumented until 2026-08-04, which cost a desktop integration a day: the
client probed `/v1/models`, `/v1/runtime/models`, and `/v1/runtime/providers`
(all correctly 404) and concluded the capability did not exist.
### `GET /v1/providers`
```json
{
"current": "modelstudio-token-plan",
"providers": [
{
"id": "modelstudio-token-plan",
"model_provider_id": "modelstudio-token-plan",
"display_name": "Alibaba Cloud Model Studio",
"default_model": "qwen3.8-max",
"has_model_catalog": true,
"credentialState": "configured"
}
]
}
```
`current` is the active generic provider id. Only the active entry carries an
exact identity: an active built-in normally repeats its canonical id in
`model_provider_id`, while an active named custom route has `current` set to
`custom` and the exact configured key there (for example `lm-studio`). Other
entries have a null exact id; a null id on the active `custom` entry identifies
the released legacy root-level custom route. Preserve both fields from the
selected entry and send a non-null exact id back as `POST /v1/threads`'s
`model_provider_id`; dropping a named custom id would collapse the selection to
the legacy root custom route. `credentialState` is a stable, non-secret
projection of the Runtime's existing structural credential classification:
- `configured`: credential material is structurally available;
- `login_required`: the route needs login or a usable login capability;
- `missing`: an API-style credential is unavailable;
- `no_auth`: the route explicitly disables credential use;
- `local`: the exact route is local and keyless;
- `legacy`: the compatibility route cannot be classified more precisely.
For an active named custom provider, this state is calculated from the exact
route named by `model_provider_id`, not from a generic custom-provider default.
It deliberately collapses saved-key and imported-token details into
`configured`, and login/consent-source details into `login_required`.
The response never includes endpoint URLs, credential environment-variable
names, filesystem paths, credential values, consent-source details, or token
metadata. `credentialState` is not a provider canary: `configured` does not
prove endpoint reachability, credential validity, model entitlement, or a
successful request. The models route below is also only a selection catalog; a
non-empty list does not prove that the route can currently serve a request.
### `GET /v1/providers/{id}/models`
```json
{
"provider": "deepseek",
"models": [
{
"id": "deepseek-v4-flash-vision-exp",
"image_input": "supported",
"reasoning_effort": "unknown",
"reasoning_effort_levels": [],
"reasoning_effort_source": null
}
]
}
```
For an exact configured route, supply `?model_provider_id=vision-work` and
require the response to echo that same `model_provider_id`. The Runtime resolves
that identity under the requested provider kind before reading model support.
Unknown or mismatched identities return `400`. Named pagination cursors bind the
configuration identity, endpoint and catalog snapshot; changing any of those
requires restarting pagination. Omitting the query preserves the legacy catalog
projection and omits the identity echo.
The catalog for one provider. Returns `400` for an unknown id, and for the
legacy `deepseek-cn` alias, which has no provider metadata — use `deepseek`.
An empty `models` array means the Runtime has no discoverable or configured
model ids for that provider; it does not report credential presence.
The ids returned here are exactly the values accepted by `POST /v1/threads`'s
`model` field and by the switch route below. `image_input` is the exact resolved
provider/model route's capability state: `supported`, `unsupported`, or
`unknown`. Keep `unknown` unknown rather than inferring from the model name or
wire protocol. `supported` describes the model route; it does not mean a given
client implements an image-upload control.
`reasoning_effort` uses the same three capability states and describes whether
the exact model's metadata publishes a selectable effort ladder.
`reasoning_effort_levels` contains only canonical, recognized active effort levels
from that metadata. Off and provider synonyms such as none are excluded: the
Apps/Chat protocol treats off as omission, which does not prove support for an
explicit provider disable command. A model capable of reasoning may still have
an unknown active effort ladder.
Codex levels are also excluded when native compatibility would change their
wire value (currently minimal and auto). This projection does not change native
compatibility behavior or advertise a tier the Runtime cannot send unchanged.
No levels are inferred from a provider-wide default or a familiar model name
on a custom endpoint. `reasoning_effort_source` identifies `catalog`,
`codex_cli_cache`, or `codex_app_server`; missing, stale, and unrecognized model
metadata stays unknown. Codex roster metadata describes the external CLI's
roster, not proof that a separately configured Runtime credential belongs to
the same account or that an authentication boundary is approved.
Pass `?model_provider_id=` when selecting a named route.
The Runtime validates the provider kind and exact identity together, returns
`model_provider_id` alongside that route's model list, and leaves the active
configuration unchanged. An empty or unknown requested identity, or a mismatched kind, returns
`400`; it never falls back to another named route.
The Runtime Chat relay publishes the same effort fields in camelCase
(`reasoningEffort`, `reasoningEffortLevels`, `reasoningEffortSource`). These
model facts do not enable tool execution or establish account entitlement.
For a thread-scoped choice, send the provider fields from the selected entry
alongside the selected model. Omit `model_provider_id` when it is null:
```json
{
"model_provider": "custom",
"model_provider_id": "lm-studio",
"model": "local-vision-model"
}
```
This creates one thread on the exact named custom route without changing the
Runtime's provider or model defaults.
### `PUT /v1/providers/{id}/key` — write-only credential
```json
// request
{ "key": "sk-…" }
// response
{ "provider": "openai", "stored": true, "backend": "keychain",
"credentialState": "configured", "configPath": "/…/config.toml" }
```
The optional `scope` field accepts `"durable"` (the default) or `"session"`.
Durable scope stores a provider API key through the same transactional write as
`codewhale auth set --provider --api-key-stdin`: the secret store under
the provider write lock, plus the `[providers.] auth_mode` metadata
marker persisted to the config document and mirrored into the live runtime
config so `GET /v1/providers` reports the new state immediately. `backend`
names which secret backend holds the key and `configPath` which config
document carries the marker (the user-global file when the ambient config
is workspace-scoped).
For a key held only in the running Engine's memory, send:
```json
// request
{ "key": "sk-…", "scope": "session" }
// response
{ "provider": "openai", "stored": false, "scope": "session",
"backend": "memory", "credentialState": "configured" }
```
A session key matches only the exact provider identity; it never falls back to
a sibling sharing a durable slot, such as `deepseek` and `deepseek-anthropic`.
It is bound to the full resolved endpoint it was set for. An accepted config
reload removes incompatible bindings; rejected reloads leave the live overlay
unchanged. Lookups through older config snapshots cannot remove newer keys. It never applies to `auth_mode = "none"` or remote
custom endpoints, and session writes to those routes are refused. Within those
limits it overrides other credential sources, except a route bound to a
required Codewhale account owner retains that requirement without fallback.
It is never serialized or written to the secret store or config document, and
does not change the live provider settings. It survives config reloads within
the Engine process while the endpoint remains the same, but does not persist
across an Engine restart. Running turns retain the key they started with;
clearing the overlay takes effect from the next turn. The response omits
`configPath` because no config document is saved.
Request bodies reject unknown fields, so an older Engine that does not support
`scope` rejects a session request instead of silently storing its key.
Set `CODEWHALE_RUNTIME_KEY_SCOPE=session` before HTTP startup to require
session-only provider keys. A durable request, including one that omits
`scope`, returns `409`. The policy is fixed for the process and preserved
through reloads. An unset or empty variable preserves normal behavior; any
other value refuses server startup. Strict mode does not use stored, environment,
subscription OAuth or configured alternate-auth headers as a fallback. Explicit
keyless routes remain keyless. Required Codewhale account ownership remains a
separate restriction and cannot be overridden with a session key.
`GET /v1/runtime/info` advertises `capabilities.session_provider_keys: true`
only in this strict mode, where both set and clear are memory-only. Browser
sign-in is unavailable in this mode. Ordinary subagents share the in-process
credential context, but external Fleet workers resolve their own credentials
and have no session-key handoff. Strict mode therefore refuses Fleet creation,
activation, restart and launch, including restored leases, and advertises its
Fleet create/start/local-target capabilities as false. It never forwards a
provider key through a worker command line, environment or config file.
Other configuration and MCP routes may still write their non-key settings to
disk; this is a provider credential policy, not a read-only filesystem.
Memory-only custody is not a vault: anyone with root access on the machine can
read a running Engine's memory. The key is not zeroized or locked against swap,
and this route does not protect it from a snapshot that captures process
memory. Existing process hardening disables core dumps on Linux when its
hardening calls succeed; failures log a warning, and that hardening is a no-op
on other platforms.
The key is never returned — there is no read route for credential material,
and neither the key nor its length appears in the response, errors, or
logs; the response carries only the readiness projection
(`credentialState`). An unknown provider id, the `deepseek-cn` legacy
alias, a key that is empty after normalization, or a normalized key over 4 KiB
is `400`. Both scopes remove whitespace, control and invisible characters with
the shared key normalizer before validation; `openai-codex` refuses API-key writes.
`credentialState: "local"` after a successful durable write is honest output for
a keyless local route: the key is
accepted, but the route classifies as not needing one.
### `POST /v1/providers/{id}/key/check` — verify a key without storing it
```json
// request
{ "key": "sk-…" }
// response
{ "provider": "openai", "valid": true, "checked": true }
```
Asks the provider's own `/models` endpoint once whether it accepts the key. It
never stores a key and never switches the provider. `checked` is `false` when
the provider has no `/models` endpoint to ask, so the key is accepted
unverified. A key the provider rejects (HTTP 401 or 403) answers `422`; any
other failure to ask (a network error, a timeout, another status) answers
`502`, which a client can treat as "could not verify" and still `PUT` the key.
The route applies `PUT`'s credential-writability rules and key-shape limits and
refuses OpenAI Codex. An Engine without this route answers `404`.
### `GET /v1/agents/external` — agent CLIs installed on this computer
```json
// response
{ "agents": [
{ "id": "opencode", "name": "OpenCode", "state": "installed",
"version": "1.19.5", "acp": "native",
"auth_methods": [{ "id": "login", "name": "Log in", "type": "agent" }],
"website": "https://opencode.ai", "adapter_installed": false, "runnable": true },
{ "id": "qoder", "name": "Qoder", "state": "not_installed", "version": null,
"acp": null, "auth_methods": [], "website": "https://qoder.com",
"adapter_installed": false, "runnable": false }
] }
```
Reports which external agent CLIs this computer already has, without starting a
session. It covers Claude, Codex, DeepSeek Harness, Antigravity (`agy acp`),
OpenCode, Kimi Code, Mistral Vibe, Qoder, CodeBuddy and Grok. `state` is `installed` or `not_installed`; a binary is
looked up on `PATH` (absolute entries only; on Windows the spawnable PATHEXT suffixes `.com/.exe/.bat/.cmd` and `%APPDATA%\npm`) and in the usual per-user bin directories. For an installed
agent the route runs `--version` and, where the CLI speaks Agent Client Protocol
itself, one `initialize` request on stdin, concurrently. `acp` is `native` when
that handshake answered, `adapter` for CLIs that need a separate adapter
process, and `unreachable` when the handshake did not answer, or answered with another protocol version, without JSON-RPC 2.0, or with an error; `reason` says which. `auth_methods`
comes from the handshake; `type: "terminal"` means the agent can only be signed
in by running it in a terminal. The probes time out after 8 s (5 s for
`--version`), receive no Codewhale or provider credentials, and leave nothing
stored. `runnable` is `true` when a thread can run on the agent now: the CLI is
not below `min_version` and either answered the handshake or has its ACP adapter
(`adapter`) installed, which `adapter_installed` reports. See "Sessions on other
agents" for starting one.
Each row also carries `depth`, `auth_status` and `reason` for the agents
screen. `depth` is `driven` when a thread can run on the agent now,
`observed` when the agent is installed but cannot be driven, and `null` when
it is not installed; a per-agent stored choice of depth is not built.
`auth_status` is `unknown`: the handshake lists sign-in methods but does not
say whether the person is signed in, and Codewhale never reads an agent's
credential files to find out. A signed-out agent is reported when a thread
first starts it ("… needs you to sign in to it first. Run `` in a
terminal"). `reason` is a sentence to show when the agent is not `driven`
(not installed, below `min_version`, no handshake answer, adapter missing),
and `null` otherwise; `reason_code` is the matching code (`not_installed`,
`below_min_version`, `no_acp`, `adapter_missing`).
### `DELETE /v1/providers/{id}/key` — clear a Codewhale-owned credential
```json
// response
{ "provider": "openai", "cleared": true, "credentialState": "missing" }
```
Removes any session key for the exact provider identity before attempting a
durable clear, even when the durable operation subsequently fails. Unless
`CODEWHALE_RUNTIME_KEY_SCOPE=session`, it also clears the durable credential
through the same shared owner as
`codewhale auth clear`: the config document is snapshotted and restored if
its save fails, the secret store is only touched once that save has landed,
and the cleared markers are mirrored into the live runtime config so
`GET /v1/providers` reports `missing` on the next read rather than after a
restart.
Under `CODEWHALE_RUNTIME_KEY_SCOPE=session`, DELETE removes only the memory
overlay and does not touch the durable secret store or config document:
```json
// response under session-only policy
{ "provider": "openai", "cleared": true, "scope": "session", "backend": "memory" }
```
This response omits `credentialState` and does not probe or clear a durable
store. After removal, subsequent turns cannot fall back to stored or environment
keys. A running parent turn and children using its already-materialized client
retain their credential until they finish; clearing memory is not provider-side
revocation. This mode validates canonical provider identity but does not apply
durable ownership checks to removing the Engine's own session key.
Clearing an already-clear route returns `cleared: true` — a client retrying
a revoke must not be told something went wrong. If the config entry is
cleared but the secret backend refuses the delete, the route answers `500`
and names the slot: reporting success while the key is still in the keyring
would be a lie about a security action.
### Credential ownership: `credentialSource` and `credentialWritable`
Durable PUT and the durable portion of DELETE refuse a route whose credential
Codewhale does not own, independently of any session key. `GET /v1/providers`
classifies that underlying durable owner so a client can disable its durable
credential control *before* submitting instead of failing late:
| `credentialSource` | `credentialWritable` | Meaning |
| --- | --- | --- |
| `secret_store` | `true` | A Codewhale-owned durable credential slot. |
| `account_session` | `false` | Codewhale account model access owns the credential; disconnect through account sign-out. |
| `config` | `false` | A literal key in a config file, which wins at request time when no session key is present. |
| `external_auth` | `false` | An active external consent (OAuth) owns the credential. |
| `none` | `false` | The route sends no credential, or has no credential slot. |
When `credentialWritable` is `false`, `credentialWritableReason` carries
user-facing copy naming the owner. Durable PUT and the durable portion of
DELETE answer `409` with that same reason. DELETE still removes the memory
overlay regardless of the durable credential owner. An existing session key
can be updated with session scope without changing that owner, subject to the
endpoint and auth-mode restrictions above. The ownership classification is
based on declared auth mode, consent state and the *kind* of any configured
`api_key` value. When account model access is bound, it also resolves the
credential source with the session overlay excluded to determine whether the
account owns the route. The result is a class and never a value, a path, or an
environment variable name.
### `POST /v1/providers/orcarouter/connect` — browser sign-in without a terminal
```json
// response
{ "attemptId": "0f3c…", "authorizeUrl": "https://www.orcarouter.ai/auth?…",
"expiresIn": 900 }
```
Runs the same OrcaRouter OAuth 2.0 + PKCE sign-in as `codewhale auth orcarouter`
for a client that has no terminal. The Engine binds its loopback callback
(`http://127.0.0.1:/callback`), returns the consent URL, and waits on a
worker for up to `expiresIn` seconds. The client opens `authorizeUrl` in the
system browser — never an embedded view — and polls the attempt. When the
browser comes back, the Engine exchanges the code, refuses a key whose granted
scope is not `api`, and stores the key through the same seam as the CLI, then
mirrors the credential markers into the live config the way
`PUT /v1/providers/{id}/key` does. It does not switch the active provider; use
`POST /v1/providers/{id}/switch` for that.
The callback listens on the Engine's own computer, so a client offers this
route only for an Engine on the same computer as the browser and outside
session-only key policy. Strict mode refuses before binding a callback or
starting authorization. A new `POST`
cancels the previous attempt, and only the current attempt can be read.
Refusals: `400` for an id other than `orcarouter`, `409` when the route's
credential is not writable (the same reason `credentialWritableReason`
carries), `500` when the callback cannot be bound. `GET /v1/providers` marks a
route that supports this with `"browserConnect": true`; the field is absent
otherwise and on Engines without the route.
### `GET /v1/providers/orcarouter/connect/{attemptId}` and `DELETE …` — status and cancel
```json
{ "attemptId": "0f3c…", "status": "failed", "reason": "key_not_saved" }
```
`status` is `waiting`, `connected`, `declined` (the user refused in the
browser), `expired` (no callback in time), `cancelled` or `failed`. `failed`
carries a `reason`: `authorization_error` (the browser came back with another
error), `callback_failed`, `not_writable` (the route's endpoint or credential authority changed before
local activation), `exchange_failed`,
`scope_refused` or `key_not_saved`. After `scope_refused` or `key_not_saved`,
OrcaRouter created a key that Codewhale did not keep; the user should revoke
it under Authorized Apps in OrcaRouter. `exchange_failed` can follow a key
OrcaRouter created before the response was lost.
`DELETE` cancels a pending attempt, including an exchange in progress, and
prevents its later local activation. It does not revoke a key already created
by OrcaRouter; revoke that key under Authorized Apps. If local activation
completed first, cancellation returns the terminal status instead. Clearing
or replacing the provider key also cancels its pending connect attempt. These
mutations retain their serialization if the HTTP client disconnects. An unknown
or superseded `attemptId` is `404`. Only the `POST` response carries the consent
URL; no status, error or log line carries it, the key, the code or the verifier.
### `POST /v1/providers/{id}/switch`
```json
// request (model is optional; omit to take the provider default)
{ "model": "qwen3.8-max" }
// response
{ "provider": "modelstudio-token-plan", "model": "qwen3.8-max",
"message": "…", "persisted": true }
```
**Use this rather than simulating a switch with repeated `POST /v1/config`
writes plus a reload.** Provider and model move together here, the change is
validated against the provider's catalog before it is applied, and `persisted`
reports whether it was written to config or applied to the live session only.
Rejects an unknown provider id and the `deepseek-cn` alias with `400`.
### `GET` and `POST /v1/config` — reply style and sub-agent limit
```json
// request
{ "key": "subagents_max_concurrent", "value": "4", "persist": true }
// response
{ "key": "subagents_max_concurrent", "value": "4", "persisted": true,
"requires_reload": true, "message": "…" }
```
`GET /v1/config` reports and `POST /v1/config` writes two config.toml keys a
person chooses:
- `verbosity`, the root reply-style key: `normal` or `concise`, compared
without regard to case. Unset reads as `normal`.
- `subagents_max_concurrent`, the `[subagents] max_concurrent` limit on
sub-agents running at once: an integer from `0` to `128`. Larger values are
stored as `128`, and `0` turns sub-agents off, as `max_concurrent = 0` does
in config.toml. Unset reads as the limit the Engine applies (top-level
`max_subagents`, else 64); the Engine never writes that default for you.
Any other value is `400`, also when `persist` is false. The response `value`
is what was stored. As with the other config.toml keys, a persisted write
takes effect after `POST /v1/config/reload`.
## Runtime data model
The runtime uses a durable Thread/Turn/Item lifecycle.
- **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`,
`model_provider` (generic kind), `model_provider_id` (optional exact configured
route), `workspace`, `mode`, `task_id`, `system_prompt`, `latest_turn_id`,
`latest_response_bookmark`, `archived`
- **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed|
failed|interrupted|canceled`), `effective_provider`, `effective_model`,
`effective_billing_surface`, timestamps, duration, usage, error summary,
`artifacts` and `workspace` (see [Turn artifacts](#turn-artifacts))
- **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message|
tool_call|file_change|command_execution|context_compaction|status|error`),
lifecycle `status`, `metadata`, `artifacts` and the legacy
`artifact_refs` projection
Events are append-only with a global monotonic `seq` for replay/resume.
`effective_billing_surface` is a non-secret classification derived from the
endpoint that served the turn. Recognized StepFun routes use `stepfun-payg` or
`stepfun-plan`; unknown and custom endpoints leave it unset. The raw base URL is
not persisted in `TurnRecord`.
### Restart semantics
- If the process restarts while a turn or item is `queued` or `in_progress`,
the recovered record is marked `interrupted` with an `"Interrupted by
process restart"` error.
- The trailing newline is an event append's commit marker. On startup, a final
JSONL fragment without that delimiter is truncated and fsynced even when its
bytes form valid JSON; it is an uncommitted append, and its already-reserved
sequence number is not reused. Newline-terminated malformed records are not
identifiable crash debris and continue to fail closed during replay.
- If a terminal turn record reached disk but its terminal event sequence did
not, the first async read reconciles any unresolved dynamic calls as
`tool_call.canceled` and then emits one `turn.completed`. Existing terminal
call and turn receipts are detected and never duplicated.
- Turn-operation bindings survive restart. Retrying the same `operation_key`
and request returns the original recovered turn (including an `interrupted`
turn recovered from an in-progress process exit); mismatched reuse remains a
conflict. A crash-created binding that never acquired a turn is discarded at
startup because engine submission happens only after both records are
durable.
- Task execution performs its own recovery on top of the same persisted
thread/turn store.
### Approval model
- The `auto_approve` flag applies to the runtime approval bridge and engine
tool context. When enabled for a thread/turn/task, approval-required tools
are auto-approved in the non-interactive runtime path, shell safety checks
run in auto-approved mode, and spawned sub-agents inherit that setting.
- When omitted, `auto_approve` defaults to `false`.
- [Authorization order](AUTHORIZATION_ORDER.md) describes where typed rules,
registered tool requirements, safety floors, repository law, approval
transport, and sandbox enforcement sit relative to one another.
### SSE event stream
The SSE event payload shape for `/v1/threads/{id}/events`:
```json
{
"schema_version": 1,
"seq": 42,
"previous_seq": 38,
"event": "item.delta",
"kind": "item.delta",
"thread_id": "thr_1234abcd",
"turn_id": "turn_5678efgh",
"item_id": "item_90ab12cd",
"timestamp": "2026-02-11T20:18:49.123Z",
"created_at": "2026-02-11T20:18:49.123Z",
"payload": {
"delta": "partial output",
"kind": "agent_message"
}
}
```
Compatibility notes:
- `schema_version` is the HTTP/SSE envelope schema version. It is independent of
the runtime store schema used for persisted thread/turn/event records.
- `event` remains the SSE event name in existing clients; it is preserved as-is.
- `kind` mirrors `event` in the stable envelope for typed clients.
- `seq` is allocated globally across all Runtime threads. Consequently, gaps
between a thread's events are normal when other threads interleave. On this
per-thread SSE stream, `previous_seq` is the sequence of the last event
delivered for this thread (or the requested replay cursor for the first
event); clients detect loss by comparing it with their accepted per-thread
cursor, not by requiring `seq == previous_seq + 1`. Sequence allocation is
also not rewound after an append is transactionally rolled back, so a retry
can intentionally skip an unused value without implying a missing event.
- `thread.started`, `turn.started`, and `turn.completed` are emitted as SSE event
names exactly as before.
- `timestamp` remains the canonical event time for schema version 1. `created_at`
is an equivalent alias for clients that use `created_at` naming elsewhere; do
not require both fields to be present.
### Ending and resuming a thread stream
Whenever the server ends a `/v1/threads/{id}/events` stream that already
returned `200`, the last frame is `stream.end`, sent exactly once and always
(it does not need `progress=true`):
```
event: stream.end
data: {"schema_version":1,"event":"stream.end","kind":"stream.end","thread_id":"thr_1234abcd","reason":"replay_failed","last_seq":42,"retryable":true}
```
- It is a transport frame, not a journal event: it has **no `seq`** and **no
SSE `id:`**. Clients that acknowledge on `seq` skip it, and a browser's
`Last-Event-ID` stays on the last real event.
- `last_seq` is the stream's cursor when it ended: the last journal `seq`
delivered on this connection, or the effective start cursor if none was
(after a `replay_limit` tail, that is already past the omitted history). It
is exactly the `since_seq` that resumes with no loss and no repeats.
- `retryable` says whether resuming from `last_seq` can succeed. Key the client
on it, not on the reason list. Treat an unknown `reason` by its `retryable`.
- There is no free-text message. The underlying error is in the Runtime log,
and it can contain store paths.
| `reason` | Meaning | `retryable` |
| --- | --- | --- |
| `replay_failed` | The durable history read that feeds the opening replay failed, including a replay worker crash after the first cursor | `true` |
| `catch_up_failed` | After broadcast lag, the durable re-read from the stream's cursor could not be opened or failed | `true` |
| `runtime_shutdown` | The Runtime API server is stopping (SIGINT, SIGTERM, or SIGHUP; Ctrl+C or Ctrl+Break on Windows). Open streams get this frame within a bounded drain window before the process exits | `true` |
Every `200` response carries `x-codewhale-stream-end: 1`. With that header, an
EOF **without** `stream.end` means the connection or the Runtime process died
without the server choosing to end the stream: a network or proxy drop, a
crash, or a kill that allows no drain (for example `SIGKILL`). A Runtime that
predates this frame sends no header; there EOF stays ambiguous, so treat it as
connection loss.
Client resume rule:
1. Keep `cursor` = the `seq` of the last journal frame you accepted. Ignore
frames with `seq <= cursor`. If a frame's `previous_seq` is not your
`cursor`, you missed events: reload the thread snapshot rather than trust
local state.
2. On `stream.end` with `retryable: true`, reconnect with
`since_seq = last_seq` after a bounded backoff, and tell the user what the
Runtime said (for example, "Runtime is shutting down — reconnecting").
With `retryable: false`, stop, show the reason, and fall back to the
snapshot.
3. On EOF or a transport error without `stream.end`, reconnect with
`since_seq = cursor` after a bounded backoff and show a connection problem,
not a Runtime error.
4. A `401`/`403` or `404` before the stream opens is terminal (a credential or
thread problem). Retry `5xx` with backoff.
5. Never reconnect from `0` to "start over": replay is idempotent only by
cursor.
Fleet streams (`/v1/fleet/runs/{run_id}/events`) keep their own end frames,
`fleet.stream.error {retryable}` and `fleet.replay.cursor_unavailable`. They
differ from `stream.end`: they carry no cursor, because a Fleet client resumes
from the opaque `cursor` of the last Fleet event it accepted.
### Steer delivery
Putting a steer into the engine's mailbox is not the same as the model reading
it. The engine discards a steer whose turn has already moved on, and an
interrupted or failed turn drops whatever it had queued. The API reports the
engine's real verdict rather than the attempt:
- The item is persisted `queued` when the steer is accepted into the mailbox.
- **Delivered.** The engine committed the text into the turn's record: the item
becomes `completed`, `steer_count` rises, and `turn.steered` + `item.completed`
are emitted. `POST .../steer` returns `200` with that turn.
- **Not delivered.** The turn moved on, was interrupted, or failed first: the
item becomes `canceled`, `steer_count` does not rise, and `turn.steer_dropped`
is emitted carrying `input`, `reason`, and the settled `item`. `POST .../steer`
returns `409`, so a client can keep the user's text and resend it rather than
clearing a composer over guidance that was never seen.
- **Still pending.** A steer sent while the engine is inside a long tool call
cannot settle until that call returns, and the request does not hang for it.
After a short wait `POST .../steer` returns `200` with the item still `queued`;
the eventual `turn.steered` or `turn.steer_dropped` event carries the verdict.
A client that treats `200` as "the model saw it" is therefore wrong in the third
case: read the item's status, or wait for the event.
Common event names: `thread.started`, `thread.forked`, `turn.started`,
`turn.lifecycle`, `turn.usage`, `turn.steered`, `turn.steer_dropped`,
`turn.interrupt_requested`,
`turn.completed`, `turn.artifacts`, `item.started`, `item.delta`, `item.completed`,
`item.failed`, `item.interrupted`, `approval.required`, `approval.decided`,
`approval.timeout`, `user_input.required`, `user_input.answered`,
`user_input.canceled`, `tool_call.requested`, `tool_call.resolved`,
`tool_call.timeout`, `tool_call.canceled`, `sandbox.denied`,
`turn.workspace_snapshot`, `turn.provider_request`, `runtime.store_failure`.
`turn.usage` is one model call's receipt, emitted when a parent model call
reports usage — not once per turn. Its `payload` carries the call's `usage`,
its `duration_ms` / `first_token_ms` / `request_ms` timings, the
`maxOutputTokens` allowance, `billed_prompt_tokens` (the prompt size the
provider billed: the last itemised request's input when retries were reported,
else the call's own input count), and `cost` — that call priced on the route it
was dispatched on, as `{usd, cny, usd_priced, cny_priced}`.
`cost` is `null` when the route cannot price every token class the call used; a
client must not read that as a free call. A currency whose `*_priced` flag is
false is unpriced. These receipts cover the parent model calls; child and
compaction usage arrive through other paths. Summed per-call prices are
therefore partial estimates, not the complete turn bill.
`turn.provider_request` retains the captured route, logical request identity,
bounded `physical_requests` identities, typed `transmission`, `coverage`, reported
usage and outcome. An observed send or absent response headers do not prove
provider acceptance or free work. Unknown primary outcomes remain unpriced in
the turn diagnostics and usage totals; known primary usage stays in parent
accounting. These receipts do not yet reconcile late cumulative primary usage.
Inference automatically retries only an ordinary rate limit or a proven
transient connection failure before dispatch. A later explicit user turn is a
new request, not an automatic replay of an uncertain request.
`turn.completed` carries the turn's authoritative cumulative token usage in
`payload.turn.usage`, including routed work. It replaces the running token
counts; it does not supply an authoritative last-turn cost. The thread totals
at `GET /v1/threads/{id}/usage` remain the accounting record. The mobile page
keeps the observed priced-call estimate after completion, marks it as a
partial estimate, and resets its figure when a new turn starts. Replayed
journal sequences are counted once. `billed_prompt_tokens` is available to
API clients; the mobile header shows token usage and partial cost.
`turn.provider_request` carries diagnostic request observations, not additional
billing usage. See [per-request cache receipts](CACHE.md#per-request-receipts).
`runtime.store_failure` is the runtime reporting a fault in the operator's own
on-disk state: a thread, turn, or item record under the session's runtime
store could not be read, parsed, or written. The payload carries `operation`
(`read` | `parse` | `write`), `record_kind` (`thread` | `turn` | `item`),
`record_id`, `path`, the full `error` chain, the root-cause `reason`, a
`next_action` (which file to move aside, or where to check free space and
permissions), and a one-line `message`. When `terminal` is `true`, the turn's
own record is unreadable or unwritable and no `turn.completed` will follow;
clients waiting on that turn should treat it as failed.
Agent-message and reasoning deltas are materialized into the item projection
before their corresponding `item.delta` event is sequenced. To avoid an fsync
for every provider fragment, adjacent deltas are coalesced to configured bounds
of at most 32 ms or approximately 16 KiB before publication (an indivisible
upstream chunk can itself exceed the byte target). A process crash inside that
unpublished window can lose the recent suffix; no durable event claims that
suffix existed. Once an `item.delta` is durable, snapshots at or beyond its
cursor include the same materialized prefix.
`approval.required` events may include a `matched_rule` string when an
execution-policy rule caused the prompt. This field is explanatory metadata for
clients and does not grant or persist permissions.
`approval.required`, `approval.decided`, and `approval.timeout` carry two
distinct identifiers. `approval_id` is the Runtime-minted, single-use capability
described under **Approvals** — the only value `POST /v1/approvals/{id}` accepts
— and `approval.required` also repeats it in the legacy `id` field for older
clients. `tool_call_id` is the Engine tool-execution ID, present for
correlation only. Automatically resolved prompts (thread `auto_approve`, and the
Auto-Review posture, which never opens a modal) mint an `approval_id` as well, so
the field has one meaning on every path; those IDs register no waiter and are
inert against the endpoint. Clients must never treat `tool_call_id` as an
approval capability or assume it is unique across threads.
The thread event stream forwards these payloads intact. The compatibility turn
stream carries `approval_id`, its `id` alias and `tool_call_id`; the pending
snapshot carries the same capability and correlator so reconnecting clients can
attach an approval prompt to its tool row. Resolutions carry their resolution
flags through that projection too: `timeout: true` marks the deny produced when
the configured decision budget expires, and `cancelled: true` marks a deny
forced by a turn interrupt or turn teardown where no user selection
was made — clients should clear the pending prompt rather than report a
refusal.
## Security boundary
- **Localhost by default**. The server binds to `127.0.0.1` by default.
`--mobile` is also loopback-only and rejects a non-loopback host until a TLS
or verified-overlay transport boundary exists. The runtime does not provide
user isolation or TLS.
- **Optional token guard**. `--auth-token` or `DEEPSEEK_RUNTIME_TOKEN`
requires a matching bearer token for `/v1/*` routes. This is a local
convenience guard, not a replacement for TLS, VPN, or a trusted reverse
proxy on public networks.
- **No provider-token custody**. The server never returns the API key. The
`api_key.source` capability field reports `env`, `config`, or `missing` —
never the key itself.
- **No hosted relay**. The app-server is a local process under the user's
control. There is no cloud component.
- **Capability responses** never leak secrets, file contents, or session
message bodies. They report *metadata*: presence, counts, status flags.
### CORS allow-list
The runtime API ships with a built-in dev-origin allow-list:
`http://localhost:3000`, `http://127.0.0.1:3000`, `http://localhost:1420`,
`http://127.0.0.1:1420`, `tauri://localhost`. To add additional origins (e.g.
when developing a UI on Vite's default `:5173`), use any of:
- CLI flag (repeatable): `codewhale serve --http --cors-origin http://localhost:5173`
- Env var (comma-separated): `DEEPSEEK_CORS_ORIGINS="http://localhost:5173,http://localhost:8080"`
- Config (`~/.codewhale/config.toml`):
```toml
[runtime_api]
cors_origins = ["http://localhost:5173"]
```
User-supplied origins **stack on top of** the built-in defaults; they do not
replace them. Wildcard origins are not supported — the explicit allow-list
model is preserved. Cross-origin preflights advertise only `Authorization`,
`Content-Type`, `Accept`, `X-Codewhale-Runtime-Token`, and the compatibility
`X-DeepSeek-Runtime-Token` request header; custom request headers are not
allowed. Added in v0.8.10 (#561), tightened in v0.9.1 (#4454).
## Managed Fleet Runtime and SDK helpers
`GET /v1/fleet/profiles` reads the captured workspace's effective roster,
including its selected Fleet and admitted plugins, without opening a run ledger.
`GET /v1/fleet/profiles/{id}/document` addresses an exact effective member ID.
Its shared `FleetProfileDocument` contract returns `id`, `kind` (`profile` or
`fleet`), `origin`, nullable `path`, `text`, opaque source-bound `revision`,
`writable` and nullable `refusal_reason`. Personal/project profile documents and
selected Fleet files return exact UTF-8 bytes through a bounded, pinned reader
(one MiB maximum). Changed identity, missing source or linked paths are refused.
Built-in, config-backed and foreign-managed members instead return an explicitly
read-only effective profile projection with no file path; sentinel paths and
native preset entry identifiers are never opened as documents.
All documents currently advertise `writable: false`. No document PUT is exposed;
use the terminal or the defining source to edit. A revision is not write
permission or a live-roster adoption receipt. Watch clients may read these
resources under the existing workspace and authentication boundaries.
The Runtime SDK lives in `npm/runtime-sdk` and is exposed as
the `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every
helper calls the local Rust Runtime API and therefore cannot bypass Codewhale's
sandbox, approval prompts, provider configuration, or fleet ledger authority.
```js
import { createRuntimeClient } from "@codewhale/runtime-sdk";
const client = createRuntimeClient({
baseUrl: "http://127.0.0.1:7878",
token: process.env.CODEWHALE_RUNTIME_TOKEN,
});
const created = await client.createFleetRun({
target: "this_computer",
roles: [{ name: "reviewer" }, { name: "verifier" }],
workflow: {
id: "release-check",
kind: "parallel",
tasks: [
{ id: "review", name: "Review", instructions: "Review locally.", worker: { role: "reviewer" } },
{ id: "verify", name: "Verify", instructions: "Verify locally.", worker: { role: "verifier" } },
],
},
});
// POST /runs only prepares durable work. This call crosses the launch gate.
await client.startFleetRun(created.run.id);
let cursor;
for await (const event of client.fleetEvents(created.run.id, { after: cursor })) {
if (event.cursor) cursor = event.cursor;
if (event.event === "fleet.replay.cursor_unavailable") {
// Reload getFleetRun(created.run.id), then reconnect without the old cursor.
}
}
```
The managed path is deliberately two-step. `POST /v1/fleet/runs` validates and
persists the run and queue without starting a worker. A separate authenticated
`POST /start` activates it and schedules the executor driver; its `202` response
reports `leased: 0` because the driver performs all leasing after it owns the
run. Creation requires named roles, one task owner per role, a `parallel`
Workflow, and an explicit Runtime target. v0.9.4 executes
only `this_computer`; `another_computer` and `cloud` return `501` rather than
silently executing locally. Worker IDs are generated per run; caller-assigned
`worker_specs` return `501` until custom workers can be given collision-free
managed identities. Parallel tasks with overlapping effective write roots are
rejected before the run is journaled. Managed `security_policy` overrides also
fail closed until that document can be enforced end to end; executable
authority comes from each named role's tool posture and bounded task workspace
scope.
Fleet helpers cover this HTTP surface:
| Helper | Runtime API route |
|---|---|
| `createFleetRun(spec)` | `POST /v1/fleet/runs` |
| `startFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/start` |
| `listFleetRuns()` | `GET /v1/fleet/runs` |
| `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` |
| `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` |
| `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` |
| `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` |
| `stopWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/stop` |
| `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` |
| `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` |
| `replayFleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events/replay` |
| `fleetEvents(runId, options)` | `GET /v1/fleet/runs/{run_id}/events` (SSE) |
`stopWorker` durably cancels that worker's active task and leaves the rest of
the Fleet running. `interruptWorker` is the compatibility name for the same
attempt-fenced cancellation transition. `stopFleetRun` cancels every queued or
active task and marks the whole run cancelled.
Replay covers aggregate run/task transitions and privacy-bounded individual
worker transitions. Event bodies omit prompts, tool call IDs, completion text,
artifact paths/checksums, and cancellation identities; bounded failure reasons
pass through secret redaction. `cursor` is opaque and stable across ordinary
appends and Runtime restarts. Clients reconnect with `after=`. A fresh
request returns a bounded newest tail and marks `history_truncated` when older
history exists. Ledger compaction can remove an old cursor; the JSON endpoint
then returns `409`, while the SSE endpoint emits
`fleet.replay.cursor_unavailable`, so the client reloads the current run
projection instead of accepting a silent gap.
`GET /v1/runtime/info` advertises `fleet_run_create`, `fleet_run_start`,
`fleet_event_replay`, `fleet_event_stream`, and `fleet_local_target`. Older
runtimes without a requested route still produce a typed SDK
`RuntimeCapabilityError`.
Verification:
```bash
npm test --workspace @codewhale/runtime-sdk
```
## Thread Fleet members
`thread_fleet_members` in runtime capabilities enables
`POST /v1/threads/{id}/members`. The body is
`{operation_key, member_id, display_name, prompt, fleet}`, where `fleet` is a
complete Fleet v2 document (the same shape as a saved `fleets/*.toml`, as JSON)
and `member_id` names one of its members with an exact `provider` and `model`.
The thread's Engine starts that member as an ordinary child of the session,
through the same spawn path as the `agent` tool: the member's provider and
model are bound exactly and never fall back to the session route, its
instructions join the child's task prompt as its Fleet profile, and the child
keeps the session's permissions and spawn limits. The document is used for this
start only; it is not saved and does not change the selected Fleet.
Member starts are durable when runtime capabilities report
`thread_fleet_member_durable_operations_v1`. The body may also carry
`expected_store_id` (the `job_operation_store_id` from `/v1/runtime/info`; a
different store answers `409 member_operation_store_changed`) and
`request_fingerprint` (checked against the server's own fingerprint of the
request; a mismatch answers `400 member_request_fingerprint_mismatch`). The
body is capped at 2 MiB (`413`).
Before the Engine is asked, the start is reserved in the Runtime store under
`(store, thread, operation_key)` with a pre-minted `agent_id`, and the child is
spawned under that id and persisted before its acceptance is written. Every
answer is a receipt `{thread_id, member_id, operation_key,
request_fingerprint, runtime_store_id, agent_id, state}`: `200` with state
`accepted` once the child exists, or `202` with state `unknown` when the thread
is mid-turn (host requests are handled between turns) and the start has not
settled within 20 seconds. Read the outcome later with
`GET /v1/threads/{id}/members/operations/{operation_key}`; an unknown key, or a
key that belongs to another thread, is `404`. `accepted` is reported only while
the thread still owns a worker with that id started for that member; otherwise
it reads as `unknown`.
Repeating the same `operation_key` with the same body returns the same receipt
and never starts a second child, including after an Engine restart or a
timeout. A different body under the same key is refused with
`409 member_operation_mismatch`. A reservation that did not produce a child
before the Engine that held it stopped is reported as `abandoned`, and a
repeated start answers `409 member_operation_abandoned`; start again with a
new key. A start the Engine refuses withdraws its reservation, so the same key
may be retried. A thread with a start still in flight cannot be deleted or
discarded until it settles.
The child appears in `/v1/agent-runs` with `owner_session_id` set to the
thread and its `member_id` as `session_name` (a display name may hold spaces or
non-ASCII text, which session names refuse); `agent.spawned`,
`agent.progress` and `agent.completed` arrive on the thread's event stream.
Stop it with `POST /v1/agent-runs/{id}/cancel`, which also stops its
descendants. A second start for a member whose child is still running is
refused by the existing session-name rule; follow up with that child instead.
## Agent Run Receipts
Sub-agent lanes persist compact run receipts in
`.codewhale/state/subagents.v1.json`. The Runtime API exposes those receipts as
a read-only inspection surface:
| Operation | Endpoint |
|---|---|
| List persisted agent runs | `GET /v1/agent-runs` |
| Inspect one run | `GET /v1/agent-runs/{run_id}` |
| Stop one run | `POST /v1/agent-runs/{run_id}/cancel` |
The response is the same worker-record shape surfaced by `agent` receipts:
`spec.run_id`, `actor_kind`, lifecycle `status`, bounded `events`,
`follow_up`, `takeover`, `artifacts`, `usage`, and `verification`. `run_id`
falls back to the worker id for older records, and `{run_id}` may be either the
run id or the worker id.
These endpoints do not start or steer sub-agents. The API surface exists so
app/editor/headless clients can inspect the same handoff receipts that the TUI
and parent model see, and stop a run they are showing.
`POST /v1/agent-runs/{run_id}/cancel` takes no body. It stops the run through
the same session-scoped path as the TUI's stop and the `agent/cancel` tool:
descendants stop with it, and a write-scoped child's changed files are named in
its result rather than dropped. It answers with the worker record:
- `200` when the record is terminal (stopping an already-finished run is a
no-op that returns its receipt);
- `202` when the owning engine accepted the stop but has not recorded the
terminal receipt within a few seconds; poll `GET /v1/agent-runs/{run_id}`;
- `404` for an unknown run;
- `409` when the run belongs to a session this runtime is not hosting (for
example a separate terminal session); stop it from that session.
## Session lifecycle (native UI supervision)
| Operation | Endpoint |
|---|---|
| List sessions | `GET /v1/sessions` |
| List session summaries | `GET /v1/sessions/summary` |
| Get session | `GET /v1/sessions/{id}` |
| Rename / archive session | `PATCH /v1/sessions/{id}` |
| Delete session | `DELETE /v1/sessions/{id}` |
| Session store repair summary | `GET /v1/sessions/repair` |
| Resume into thread | `POST /v1/sessions/{id}/resume-thread` |
| Create thread | `POST /v1/threads` |
| List threads | `GET /v1/threads` |
| Attach to events | `GET /v1/threads/{id}/events?since_seq=0` |
| Send message | `POST /v1/threads/{id}/turns` |
| Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` |
| Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` |
| Compact | `POST /v1/threads/{id}/compact` |
## Compatibility tests
Contract snapshots live in `crates/protocol/tests/`. Run:
```bash
cargo test -p codewhale-protocol --test parity_protocol --locked
```
This validates that the app-server's event schema hasn't drifted from the
documented contract. CI runs this on every push to `main` and on release tags.
The app-server stdio control surface has its own drift guard — the advertised
`capabilities` method set is pinned in `crates/app-server/src/tests.rs`:
```bash
cargo test -p codewhale-app-server capabilities
```
Before a release, run the headless smoke (stdio probe + optional provider
matrix, no secrets leaked):
```bash
scripts/release/app-server-smoke.sh --matrix # dry-run plan
bash scripts/release/app-server-smoke.test.sh # parser self-test (fake binary)
```
## Profile constitution
`profile_constitution` in runtime capabilities enables profile snapshots on
`POST /v1/threads/{id}/turns`. The optional `profile_constitution` field contains
`{accountId, revision, constitution}`. `constitution` is exactly
`{schemaVersion: 1, detail, initiative, collaboration, notes}`; choices are
`brief|balanced|detailed`, `check|judgment|moving`, and `direct|critical|coach`.
Notes are limited to 4,000 Unicode characters. Invalid data is refused.
The authenticated account transport supplies the snapshot, which participates in
the turn's replay identity. The Engine renders it through its existing personal
constitution renderer and records it in native session history. The snapshot is
unchanged across provider retries and compaction. A new snapshot fully replaces
earlier personal preferences. Permissions and approval policy are unaffected.
Internal follow-ups and RLM child calls inherit the admitted preferences;
they do not re-read the host operator's account in the middle of that work.
Without a supplied snapshot, the Engine reads the signed-in profile from the
configured account service at turn admission. When that Engine-loaded profile is
unavailable or invalid, the turn uses the signed-out local constitution and shows
a notice once per session; an invalid host-supplied `profile_constitution` snapshot still fails
the turn, and `GET /v1/constitution` still reports the error. An account without saved
preferences uses an explicit default snapshot; a signed-out account uses the
existing local constitution. Hosted transports always supply the owning
account snapshot, including defaults, so local preferences cannot leak between
accounts.
`GET /v1/constitution` reads the next-turn profile and its model guidance. It is
not a receipt that an active turn adopted the edit. `POST /v1/constitution/preview`
accepts a constitution document and returns `{modelGuidance, saved:false}` without
saving anything. `modelGuidance` is the Engine-rendered guidance body: GET and
preview return identical text for the same document. It excludes the session
record's attribution line or envelope; `source` and `revision` carry provenance.
Both routes use normal Runtime authorization. Older runtimes
must be upgraded before account transports submit profile-bearing turns.
### Attested watch-event turns (preview)
`watch_event_turns` is true only when the dedicated route and configured Runtime bearer exist. `POST /v1/threads/{id}/watch-turns` accepts only `operation_key`, `prompt`, and `watch_event`. It requires the actual master `Authorization: Bearer ...` header. Device Watch/Drive tokens, cookies, token-header aliases and insecure no-auth do not authorize this route. The existing thread's provider, workspace, tools and permissions remain authoritative; the body cannot grant or replace them.
The closed version-1 receipt is at most 4 KiB when normalized. Required fields are `version`, `platformAccountId`, `projectId`, `watchId`, `watchRevision`, `definitionSha256`, `sourceKind`, `occurrenceSha256`, `eventSha256`, `runId`, `roomThreadRootId`, and `source`. References are bounded ASCII (200 bytes), except `repo` has two `[A-Za-z0-9_.-]` components of 1..100 bytes each (201 including `/`). Revisions are positive safe integers and hashes are lowercase SHA-256. `roomThreadRootId` is empty or a `pm_` id. `sourceKind: "schedule"` carries only `source.slot`, the exact canonical 24-byte UTC ISO schedule occurrence `YYYY-MM-DDTHH:mm:ss.sssZ` with a valid calendar/time (no offsets, date-only or leap seconds); `"github.pull_request"` carries only installationId, bindingId, repo, number, deliveryId and eventName. Unknown fields, mismatched kind/source or grant fields refuse. Runtime-owner attestation does not independently verify Platform's webhook signature or current watch policy.
This manager-owned source uses Runtime provenance through the same turn admission and execution path. It cannot count as a person's answer or resume a goal requiring that answer. The operation fingerprint includes normalized intent, prompt and Runtime-derived owner/thread/workspace context. Fresh admission returns 201, exact replay 200, conflicting intent 409. UserMessage metadata durably retains `input_provenance: "watch_event"`, `watch_event`, SHA-256 of the exact UTF-8 admitted prompt (preserved without trimming for typed watch input) as `input_prompt_sha256`, and the derived `runtime_binding`. Metadata alone never selects this authority. Ordinary `/turns` and the local `/rc` consumer refuse watch authority.
The existing authenticated operation lookup returns the same flat TurnRecord for ordinary turns and additive watch fields from that durable input for watch turns. It holds the existing shared operation claim while reading, creates no record or lock, and never starts a turn. Missing, incomplete or mismatched lookup is not proof of nonadmission. A Platform accepted command ACK precedes native admission; recovery may acknowledge an exact already-admitted receipt but must not send a replacement POST on absence, revocation or uncertainty. Disable/re-enable fencing belongs to Platform's existing queue and revalidation owner. This route does not make revocation atomic with later dispatch or confirm cancellation of earlier accepted work, and it does not implement whole continuous operation.
### App Server wire migration status
The selected reference is Codex `rust-v0.166.0`, commit
`6ff1e1ca3b30fc8b914720681fcf5fe62e95f856`. The shared RPC envelope and
initialization lifecycle serve JSONL stdio and private-channel WebSocket text
messages. The decoder includes request, notification, response and error
variants with signed-i64 or string IDs. Legacy thread/message and response
events remain compatibility methods; they do not establish Codex thread/turn/item
parity. TCP WebSocket listener, full-tool turn/start, independent Codex subscriptions, bidirectional interactions, durable idempotent
settlement and the complete SDK remain required. The native Engine journal
and turn loop remain authoritative. No default approval deadline is introduced.
Owner restart interrupts active work rather than re-offering pending interactions.
`/v1/chat/completions` admits one nonempty user text message through the existing
manager and journal. Unsupported histories, system/tool messages, tools, images,
streaming and other options refuse before admission. Standard usage and
finish_reason remain null until their native completeness mapping is implemented;
no zero or final-reason guess is made. This bounded compatibility input does not
establish complete chat or Codex parity.
## Local whale voice
A local whale's `SOUL.md` describes its personality and voice. Read the exact local file with `GET /v1/whale/soul` and save it with `PUT /v1/whale/soul`. Both operations require the host Runtime token. The response carries the path, text, revision and writable state. Send the returned revision quoted in `If-Match` with a JSON object containing `text`. A stale edit returns HTTP 412 so the editor can keep the draft. Files are private to the current OS user (mode 0600 on Unix), UTF-8, regular single-link files with a 16 KiB limit. An existing `SOUL.md` requires a matching `whale.toml` identity (`id = "default"`); an absent local whale is created only by an explicit save. A concurrent edit returns HTTP 409 instead of waiting for the other writer. An explicitly imported account whale has a read-only local copy.
The root Engine captures the local whale's voice at turn admission and charges it to the global instruction budget. The instruction receipt includes `whale_soul_source_path` and `whale_soul_revision`; internal follow-ups reuse the captured voice. Its personality applies within the existing permissions.