# HTTP API Reference Codeman's HTTP API is a **stable contract** as of 1.0 — see [`versioning-policy.md`](versioning-policy.md) for the SemVer guarantee. This page defines the response envelope, status codes, error codes, versioning, and the SSE event channel. ## Versioning - The stable, public surface is served under **`/api/v1/...`**. Pin external clients to this prefix. - The unversioned **`/api/...`** paths are a permanent alias of the current version (what the bundled web UI uses). They are kept working, but new external integrations should use `/api/v1`. - Breaking changes to the contract ship under a new prefix (`/api/v2`); `/api/v1` keeps its semantics. Additive changes (new endpoints, new optional fields, new error codes) are non-breaking and may appear in a minor release. - The implementation rewrites `/api/v1/*` → `/api/*` at the server level (`rewriteApiV1Url` in `src/web/server.ts`). ## Response envelope Every JSON response uses one uniform envelope, applied centrally by a `preSerialization` hook (`src/web/server.ts`) — handlers return bare data and the hook wraps it: **Success** — HTTP `2xx`: ```json { "success": true, "data": } ``` `data` is the endpoint's payload (object, array, or value). Endpoints with no payload return `{ "success": true, "data": {} }`. **Error** — HTTP `4xx`/`5xx`: ```json { "success": false, "error": "human-readable message", "errorCode": "NOT_FOUND" } ``` `ApiResponse` in `src/types/api.ts` is the canonical type. > Non-JSON endpoints are exempt from the envelope: `GET /api/sessions/:id/file-raw`, > `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`, > `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), > `GET /html-view/:cap/*` (a [rendered HTML file](#rendered-html-files) and its > assets), and the `GET /ws/sessions/:id/terminal` WebSocket upgrade. > > **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and > `GET /api/screenshots/:name` keep working but log a one-time warning on first > use. They are removed in a later MAJOR, after at least one MINOR release that > carries this warning (see `docs/versioning-policy.md`). To hand > a file to an agent, use `POST /api/sessions/:id/paste-image`, which saves it into > that session's workspace. > The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but > are the only JSON endpoints that deliberately **hold the connection open**, for up > to 600 s. Proxy operators and HTTP clients with a global read timeout need to know > that before pointing them at Codeman. ⚠️ **A `401` is the one status that is not an envelope.** Authentication is rejected in a request hook, before any handler runs, and it replies with the bare string `Unauthorized` (`Unauthorized: hook secret required` on the hook path) plus `WWW-Authenticate: Basic realm="Codeman"`. There is no `success`, no `error`, and no `errorCode`, because the wrapping hook only wraps object payloads. So a client that pipes every response straight into a JSON parser dies with a parse error rather than reporting an auth failure, which is a confusing way to discover that a password is set. Branch on the HTTP status **before** parsing. ## Error codes → HTTP status The single source of truth is `ErrorStatus` / `httpStatusForErrorCode()` in `src/types/api.ts`. Clients should branch on `errorCode` (stable) and may rely on the HTTP status. | `errorCode` | HTTP | Meaning | |-------------|------|---------| | `INVALID_INPUT` | 400 | Malformed request / failed validation | | `UNAUTHORIZED` | 401 | Authentication required or failed | | `FORBIDDEN` | 403 | Signed in, but not allowed (e.g. a non-admin calling an admin-only route in multi-user mode) | | `PASSWORD_CHANGE_REQUIRED` | 403 | Multi-user: the user must change their password before anything else | | `NOT_FOUND` | 404 | Resource does not exist | | `USER_NOT_FOUND` | 404 | Multi-user: no user with that name | | `SESSION_BUSY` | 409 | Session is busy | | `CONFLICT` | 409 | Conflicts with current state (e.g. already running) | | `ALREADY_EXISTS` | 409 | Resource already exists | | `USER_EXISTS` | 409 | Multi-user: a user with that name already exists | | `LAST_ADMIN` | 409 | Multi-user: refusing to demote, disable or delete the last enabled admin | | `OPERATION_FAILED` | 422 | Well-formed but could not be completed | | `RATE_LIMITED` | 429 | Too many requests | | `INTERNAL_ERROR` | 500 | Unexpected server error | Adding a new error code is non-breaking; removing or renaming one is a major change. ## Cron jobs Saved jobs and their launch history are separate from the legacy `/api/scheduled` duration-bounded loops. Use `/api/v1/cron/...` in external clients; `/api/cron/...` is the unversioned alias. These routes use the response envelope above; the table lists the value inside `data` on success. | Method | Path | Request body | Response `data` | | --- | --- | --- | --- | | GET | `/api/v1/cron/jobs` | None | `CronJob[]` | | POST | `/api/v1/cron/jobs` | Full job definition below | `{ job: CronJob }` | | GET | `/api/v1/cron/jobs/:id` | None | `CronJob` | | PUT | `/api/v1/cron/jobs/:id` | Partial job definition | `{ job: CronJob }` | | DELETE | `/api/v1/cron/jobs/:id` | None | `{}` | | PUT | `/api/v1/cron/jobs/:id/enabled` | `{ enabled: boolean }` | `{ job: CronJob }` | | POST | `/api/v1/cron/jobs/:id/run` | None | `{ run: CronJobRun, activeAgents: number }` | | GET | `/api/v1/cron/jobs/:id/runs` | None | `CronJobRun[]` | | GET | `/api/v1/cron/runs` | None | `CronJobRun[]` | ### Job request fields The create body requires `name`, `agentType`, `workingDir`, `promptMode`, `inputMode`, `scheduleType`, `enabled`, and `concurrencyPolicy`. Additional fields are required according to the selected prompt and schedule: | Field | Type / validation | | --- | --- | | `name` | String, 1–200 characters | | `agentType` | A supported session mode (including `shell`) | | `workingDir` | Existing, allowed working-directory path | | `launchCommand` | Optional single-line string, at most 2000 characters; for shell jobs | | `promptMode` | `inline_text` or `prompt_file_path` | | `promptText` | Required for `inline_text`; nonempty single-line string, at most 100000 characters | | `promptFilePath` | Required for `prompt_file_path`; absolute path inside `workingDir` to a regular file, at most 1 MiB, read when the job fires | | `inputMode` | `paste` or `typed` | | `scheduleType` | `once`, `interval`, `daily`, or `weekly` | | `runAt` | Required for `once`; positive integer Unix timestamp in milliseconds | | `intervalMinutes` | Required for `interval`; integer from 1 to 525600 | | `dailyTime` | Required for `daily`; `HH:MM` in server-local time | | `weeklyDays` | Required for `weekly`; 1–7 weekday integers, 0 (Sunday) through 6 (Saturday) | | `weeklyTime` | Required for `weekly`; `HH:MM` in server-local time | | `enabled` | Boolean | | `concurrencyPolicy` | `warn_only` or `skip_if_same_agent_running`; scheduled runs only | | `autoClosePreviousSession` | Optional boolean, default `true`; ignored for `once` | | `notes` | Optional string, at most 2000 characters | `PUT /jobs/:id` accepts any subset of these fields, then validates the merged job. When changing `promptMode` or `scheduleType`, supply the fields the new mode needs. `Run Now` works even when the job is disabled, bypasses the scheduled concurrency policy, and does not change the schedule. `activeAgents` counts live sessions of the same agent type, excluding sessions created by this job. For recurring jobs with `autoClosePreviousSession` enabled (the default), `Run Now` also closes the previous run's session before launching, even if it is still working. ### Job and run response fields `CronJob` contains the request fields plus server-maintained `id`, optional `owner` (multi-user mode), `createdAt`, `updatedAt`, `lastRunAt`, `nextRunAt`, `lastStatus`, `lastDueKey`, and optional `completedOnce`. Times are Unix milliseconds; `lastRunAt`, `nextRunAt`, `lastStatus`, and `lastDueKey` can be `null`. `lastDueKey` is an opaque internal duplicate-launch guard, not a stable API format. `CronJobRun` contains `id`, `cronJobId`, nullable `sessionId` and `sessionName`, `startedAt`, nullable `finishedAt`, `status`, optional `errorMessage`, `triggerType` (`scheduled` or `manual_run_now`), and nullable `createdSessionUrl`. Run times are also Unix milliseconds. Status is one of `created`, `session_started`, `prompt_sent`, `failed`, or `skipped`. Prompt delivery continues asynchronously after session launch, so `Run Now` can return `session_started` before the prompt is sent. Read run history for subsequent updates, but do not assume a terminal status will follow: if the session is closed during the readiness wait or the server restarts before delivery, the run can remain `session_started` indefinitely with `finishedAt: null`. `finishedAt` refers to the launch/prompt-delivery attempt, **not completion of the agent's task**; `prompt_sent` does not prove that the task succeeded. In multi-user mode, list/history endpoints filter to accessible jobs. An unknown or inaccessible job returns `NOT_FOUND`. Job creation and updates can return `403 FORBIDDEN` for a working directory outside the owner's workspace or a shell / launch-command job without the required privilege grant. Invalid definitions or working directories return `INVALID_INPUT`; launch/delivery failures are recorded on the run, so inspect its `status` and `errorMessage` even after an HTTP success. See [Cron Jobs](wiki/Cron-Jobs.md) for the UI, scheduling, and prompt-file rules. See the [complete cron guide](cron-guide.md) for the `cron:runCreated` and `cron:runUpdated` SSE events. ## Long-polling (agent wait) Three calls block until something happens instead of answering immediately. They exist because SSE is Codeman's only other "tell me when" channel, and an agent driving the API from a shell tool cannot practically hold a stream and parse events inline. | Call | Blocks until | |------|--------------| | `GET /api/v1/sessions/:id/wait` | one of a set of lifecycle signals fires | | `GET /api/v1/sessions/:id/wait-output` | a literal string appears in the session's output | | `POST /api/v1/sessions/:id/input` with `wait` | the input is delivered **and then** a signal fires | `POST .../input` with `wait` is not the same as a `POST` followed by a separate `GET .../wait`. It registers the waiter **before** writing, which closes the window in which a separate wait sees the session still idle from the previous turn and answers instantly with the wrong turn's result. Use it whenever you send a prompt and want to know when that prompt is done. ### Three semantics that break callers who assume otherwise **1. A timeout is HTTP `200`, not an error.** A wait that ends without its signal returns `{"success":true, ...,"wait":{"timedOut":true,"signal":null}}`. The intended pattern is a client-side loop over short waits, because `tailscale serve` and cloudflared can both cut an idle connection, and turning every poll boundary into a `4xx` would make that loop indistinguishable from a real failure. `408` is auto-retried by several clients (silently doubling the polling load), `504` is what a genuine tunnel failure looks like, and `204` cannot carry `waitedMs` / `status` / `limitPaused`. Reserve error handling for the four codes in the table below. **2. `stop` fires only for `claude` (and `deepseek`) sessions, `blocked` also for `codex`.** Both come from Claude Code hooks, and no other mode installs them: `shell` runs no agent, and the external CLIs render their own TUIs and post no hooks (`deepseek`'s status bridge reports the same two, see its own docs). The one exception is `blocked` on `codex`: Codeman reads codex's approval, question and trust dialogs off its screen and raises `blocked` for them (see [Signals](#signals)). For every other mode only `idle`, `working` and `exit` are accepted. `exit` is dependable everywhere; `idle` and `working` are screen-checked for `opencode`, `gemini`, `pi`, `omp` and `antigravity` (as for `claude` and `codex`) and not guaranteed elsewhere: see the caveats under [Signals](#signals) before building on `idle`. Requesting a signal the session can never emit **explicitly** is a `400` (`stop` on `codex` included); omitting `until` never fails, the server just drops them from the default set and echoes the narrowed set back as `wait.until`. Three more places hooks can go missing even in `claude` mode: a **Docker case** needs `CODEMAN_DOCKER_BRIDGE_HOOKS=1`, since a container cannot reach a loopback-bound Codeman (without it, only `idle` / `working` / `exit` work); a **remote-SSH case** runs the agent on another host, whose hooks may never reach this server at all; and a case whose hook config was written by **Codeman < 1.13.0 against an `--https` install** carries hook curls without `-k`, which TLS-fail silently (the hook line ends in `|| true`). Codeman now writes `curl -sk` and repairs a stale case config the next time a session starts in that case. When in doubt, ask for `stop,idle,exit` so a session without hooks still resolves on the heuristic signal. **3. `from=now` does not mean "printed after you asked".** tmux repaints the visible screen on attach, on resize, and on any TUI redraw, and a repaint arrives as ordinary output, so text that was already on screen can satisfy a fresh wait. This was observed live: a marker echoed a minute earlier matched instantly on a new `from=now` wait. It is inherent to running the agent under a multiplexer, so the contract is a **marker unique to each call** (`MARK="DONE_$RANDOM"`, send `echo $MARK`, then wait on `$MARK`), never a generic string like `BUILD OK`. ### Signals | Signal | Source | Actually fires for | |--------|--------|--------------------| | `idle` | the session's own `idle` event | `claude`: yes, on ❯-prompt detection after activity. `codex`, `opencode`, `gemini`, `pi`, `omp`, `antigravity`: yes, the same screen check through the prompt marker and working line each declares (`capabilities.workDetect`); an `antigravity` session waiting on a shell-command approval stays `working` until it is answered. `shell`: **once only**, ~500 ms after start, and never again. Other external CLIs (`grok`, `deepseek`): not guaranteed (they render their own TUIs and readiness is output stabilization) | | `working` | the session's own `working` event | `claude`, plus `codex`, `opencode`, `gemini`, `pi`, `omp` and `antigravity` through the working line each declares. Elsewhere not guaranteed: it rests on generic output heuristics such as a braille spinner glyph | | `stop` | the Claude Code `stop` hook, the definitive end-of-turn signal | `claude` (and `deepseek`) only | | `blocked` | a `permission_prompt` or `elicitation_dialog` hook; on `codex`, a dialog read off its screen | `claude`, `deepseek` and `codex`; on `claude` rarer than it looks: see below | | `exit` | no process is behind the session | every mode | `stop` is the signal to orchestrate on where it exists; `idle` is a heuristic fallback that can flap mid-turn when a spinner pauses. The default set when `until` is omitted is `stop,idle,exit` (`exit` is in there so a worker that crashes resolves the wait promptly instead of burning the caller's whole timeout on something that can no longer happen). On a `claude` worker, prefer an explicit `until=stop,exit` once the session is up: the default set's `idle` also resolves on a spinner pause, and on a fresh session the **startup** `idle` (emitted when the CLI first comes up) can land inside your first wait window and report a turn that never ran. That startup `idle` is held while Claude's workspace-trust dialog is on screen (inside the 90 s startup window), because the dialog's own ❯ is not a composer. ⚠️ **`exit` means "nothing is running", which includes "not started yet".** The server answers from `pid === null` plus a mux-layer pane-death probe, and that covers a session that exited — including a worker that died *inside* its tmux pane while the local attach client (and therefore `pid`) lives on — one that was detached, and one that was **created but never started**. So the first wait after `POST /api/v1/sessions` returns `{"signal":"exit","immediate":true}` in milliseconds, and reading that as "the worker died" is wrong: it means start it, or wait for it to come up. `status` is carried alongside so nothing is hidden. The alternative (trusting `status`) is worse, because a dead PTY parks the session at `status: "idle"`, which would answer the default wait with `immediate: true` for a worker that has crashed. A worker dying while a wait is parked resolves it within a few seconds (a background death-watcher), not at the timeout. ⚠️ **`blocked` is reachable less often than the table suggests.** It fires on two hooks, and the default configuration suppresses one of them: Codeman spawns claude with `--dangerously-skip-permissions`, so permission prompts do not happen unless the instance is switched to the `auto` Claude mode (App Settings), or the caller is a multi-user account without the bypass grant, which is forced to `--permission-mode auto`. What does still fire under the default is `elicitation_dialog`, the agent asking the user a question. So `until=stop,blocked,exit` is a reasonable belt on a long turn, but a worker that never comes back is far more likely to be working than blocked, and polling `blocked` alone will sit at its timeout. **`blocked` on `codex` is read off the screen**, because codex has no hooks and is launched with its approvals on unless `codexConfig.dangerouslyBypassApprovals` is set (`codexConfig.networkAccess` opens the sandbox's network and does not change that). A codex pane showing a command, edit or network approval, a question (`request_user_input`) or the startup trust prompt raises `blocked` (plus the red alert, push and webhook a Claude `permission_prompt` raises, alert-only: answer it in the terminal), and the session reads `idle` while it is up. Unlike a hook, this is a level the server keeps reading, so a wait for `blocked` that arrives while the dialog is already up answers at once (`immediate: true`); a wait that did not ask for `blocked` is unaffected. It clears 2 to 4 s after the dialog leaves the screen. `stop` stays a `400` on codex: nothing on a screen says a turn ended. ⚠️ **On a `shell` session, only `exit` and marker-matching are dependable.** A shell session emits its one `idle` at startup and then stays `status: "idle"` forever, whatever the pane is doing, so it never emits a *transition*. Since send-and-wait requires a transition (and so does `fresh=1`), both can only time out there: a documented default `wait` on a shell worker running `sleep 4` times out at the full 25 s. Synchronize hook-less sessions with `wait-output` and a unique marker instead. The same caution applies to the external CLIs. ### Readiness is not a signal Nothing here reports "the agent is ready for a prompt", and no combination of `until`/`fresh` synthesizes one. A freshly created session reads as `exit` (above), and a `claude` worker in a brand-new case comes up on the CLI's **trust dialog**, which contains a ❯ of its own. A prompt posted at that moment is typed into the dialog, and its `\r` confirms the highlighted `No, exit`: the CLI quits and the pane dies, so send-and-wait resolves on `exit`. Codeman holds the session's `idle` while the dialog is on screen, so waiting for `idle` before prompting no longer clears too early, but that still does not make `idle` a readiness signal. The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is non-null, then `wait-output` for the composer's own marker (`bypass`, the status bar of a CLI spawned in bypass mode) with a short timeout, handling the trust dialog only as the bounded fallback. ⚠️ **The fallback is not a bare `\r`.** Claude Code 2.1.252 unnumbered the dialog's options, reversed them and highlights `No, exit`, so an Enter sent blind quits the CLI and the pane dies seconds after the spawn. Read the `❯` marker off the current frame (`GET /api/v1/sessions/:id/terminal?full=1`), send `ESC [ B` while it is on `No, exit`, re-read, and confirm only once it is on `Yes, I trust this folder`. Reading the current frame is also what keeps this correct on later runs: the dialog text stays in the terminal buffer for the life of the session, so a `trust` probe with `from=buffer` keeps matching long after the dialog is gone. A worked version is in [`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli). ### `GET /api/v1/sessions/:id/wait` | Param | Type | Default | Notes | |-------|------|---------|-------| | `until` | comma-separated list of `idle,working,stop,blocked,exit` | `stop,idle,exit` | resolves on the first to fire. An unknown token is a `400` naming it, never a silent fallback | | `timeout` | positive integer ms | `60000` | **validated first, clamped second.** `0`, a negative value and a fractional value are all `400`s, not clamps; a valid value outside `[1000, 600000]` is clamped and echoed as `wait.timeoutMs` | | `fresh` | `0` \| `1` \| `false` \| `true` | `0` | `1` requires an actual transition, ignoring the state at call time | ```bash curl -s "$API/api/v1/sessions/$SID/wait?until=stop,exit&timeout=60000" ``` Both GET wait routes answer with `Cache-Control: no-store`, because the documented pattern polls one identical URL in a loop and a cached `{"timedOut":true}` would turn that loop into a busy spin. `POST .../input` sends no cache header (it is a POST, which is not heuristically cacheable). ⚠️ **Unknown query parameters are ignored, not rejected**, with one exception (`regex`, below). In particular `match=` on `/wait` is silently dropped and you get a plain signal wait, so check the endpoint path before blaming the parameters. ### `GET /api/v1/sessions/:id/wait-output` | Param | Type | Default | Notes | |-------|------|---------|-------| | `match` | literal string, 1 to 200 chars | required | substring match against the PTY stream with ANSI escapes stripped. A match spanning two PTY chunks is found | | `nocase` | `0` \| `1` \| `false` \| `true` | `0` | case-insensitive compare. The returned snippet keeps the terminal's original casing | | `from` | `now` \| `buffer` | `now` | `buffer` scans the tail of the existing terminal buffer (bounded, 256 KB by default) before blocking | | `timeout` | positive integer ms | `60000` | same validation and clamp as `/wait` | **Matching is literal, never a pattern.** A `regex` parameter is rejected with a `400` rather than ignored, so a caller that assumed otherwise finds out immediately instead of waiting on the wrong thing. The reasoning is in [`architecture-invariants.md`](architecture-invariants.md#agent-wait-primitives). #### What the matcher actually sees The matcher scans the raw PTY stream, **normalized**: ANSI escape sequences are stripped — CSI, OSC, and the charset-designation escapes a stock bash prompt emits on every line (`ESC ( B`), so `match=tnode:` matches a prompt that renders `…@tnode:` — a partial escape arriving at a chunk boundary is held back until its tail arrives, and a match may straddle PTY chunks: `printf STRAD; sleep 1; printf DLEQQ` is matchable as `STRADDLEQQ` (all measured live). Three caveats remain: ⚠️ **It is still the byte stream, not the rendered pane.** `GET .../terminal` answers from a tmux screen capture (`data.source: "mux-visible"`), the finished picture; the matcher sees the stream that painted it. For linear output the two agree once escapes are stripped, but a full-screen TUI composes its picture with cursor positioning, so what the pane shows and what the stream carries can differ. Seeing your string in `terminal?tail=` makes a match likely, not guaranteed. ⚠️ **A TUI's text can arrive without its spaces.** Claude Code positions words with cursor moves rather than printing spaces, so screen text can reach the matcher as `Quicksafetycheck:Isthisaprojectyoucreated...`. Whether a given phrase keeps its spaces depends on how the TUI happened to draw it (measured: `I trust this folder` matched, `Quick safety check` did not), so a multi-word `match` against a TUI pane is unreliable rather than impossible. Match a **single space-free token**, ideally one you printed yourself. Plain command output (a shell worker, an `echo`) keeps its spaces. ⚠️ **The returned `snippet` is a rendering of the matched text, not a quotation of it.** It is cut from the same normalized stream the match ran against, then cleaned for display: remaining raw control bytes are removed (an agent pipes the snippet into its own terminal, so a worker's bytes must not be able to reset that display) and blank runs are collapsed. A printable needle that matched will appear in it; a needle containing control bytes or a blank run may not survive verbatim. ```bash MARK="DONE_$RANDOM" curl -sG "$API/api/v1/sessions/$SID/wait-output" \ --data-urlencode "match=$MARK" --data-urlencode 'timeout=120000' ``` Build the query with `-G --data-urlencode` rather than by hand: a `+` in a hand-written query string decodes to a space. ### `POST /api/v1/sessions/:id/input` with `wait` Two optional fields on the existing endpoint: | Field | Type | Notes | |-------|------|-------| | `wait` | `true` or the same comma grammar as `until` | `true` means the default signal set. Omitted keeps the historical fire-and-forget behavior, unchanged. `null`, `false` and an empty string are all read as **absent**, not as an error and not as "wait for the default" | | `waitTimeout` | positive integer ms | same validation **and** clamp as `timeout`: `0`, a negative and a fractional value are `400`s, anything valid is clamped into `[1000, 600000]` and echoed as `wait.timeoutMs` | Both are `nullish`, so an explicit `null` from `JSON.stringify` is accepted as "absent" rather than failing validation. That is deliberate: `.optional()` would reject it, which has shipped as a real bug twice. The input must end with `\r` (a real carriage return in the JSON string): Enter is sent only when the input contains one, so text without it is typed onto the worker's prompt but never submitted, and the wait then runs its full timeout on a turn that never started. Verified live; this is the most common silent failure on this endpoint. Input tagged with `clientId` and `seq`, waiting or not, answers `409 CONFLICT` with nothing written while an experimental guarded submit holds the session's input lease, or input held behind it is still being written (a few milliseconds; see [guarded input](#experimental-bounded-agent-inspection-and-guarded-input)). Retry it. Untagged input is held instead and written in arrival order once the lease is released. A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is delivered through tmux even without `useMux`: the text is typed, Enter is pressed as a separate key, and the server re-presses Enter while the prompt is still visibly sitting on the composer. Written straight into the pane in one piece, a prompt of about a hundred characters or more is taken as a paste by Claude Code, its `\r` becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps the raw write, and an explicit `"useMux": false` forces it. With `"useMux": true`, a line feed is kept: the text goes in as one bracketed paste (`\r\n` read as `\n`, leading and trailing line breaks dropped), then Enter if the input carried a `\r`; a pane that has not enabled bracketed paste receives each line break as a carriage return and runs the lines as they arrive. The check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so a long multi-line prompt to a just-started agent is still safer in a file it is told to read. ```bash curl -s -X POST "$API/api/v1/sessions/$SID/input" \ -H 'Content-Type: application/json' \ -d '{"input":"run the tests\r","useMux":true,"clientId":"agent-1","seq":1, "wait":"stop","waitTimeout":600000}' ``` A **tagged duplicate** (a `clientId` + `seq` pair the server has already applied) still honors `wait`, because the caller's question is unanswered, but it answers from the session's current state rather than requiring a new transition: the original turn may be long over. It comes back as `"delivered": false, "duplicate": true`. **Wake-on-LAN hosts** (`docs/remote-sessions.md` §Wake-on-LAN): when the session's remote host has a wake target and is asleep, the non-wait form answers `200` with `{"buffered": true}` — the bytes are held and flushed after the host is back — or `{"buffered": true, "dropped": true}` for a chunk over the 4 KB wake buffer, which is gone (never delivered as a fragment). Both fields are additive to the historical bare `{}`. With `wait`, the route blocks on the wake instead and answers `422 OPERATION_FAILED` ("did not come back after a wake-on-LAN request — nothing was sent") when the host never returns, rather than writing into the stalled pane and reporting `delivered:true` plus a timeout. Two endpoints back that flow directly, both scoped to one session's remote host and both refusing a session that is not remote (`400 INVALID_INPUT`): | Method | Path | Purpose | | --- | --- | --- | | `GET` | `/api/sessions/:id/reachability` | Whether the session's remote host answers SSH right now, plus whether a wake target is configured. Read-only: it never wakes. `{"reachable": true\|false\|null, "wakeConfigured": "mac"\|"command"\|"none"}`, where `null` means the answer is unknown (a proxied host, where a TCP probe proves nothing). | | `POST` | `/api/sessions/:id/wake` | Wake the host and wait for it to accept SSH again, bounded by the request budget. `422 OPERATION_FAILED` when it does not come back; `400 INVALID_INPUT` with "No wake-on-LAN target configured for this host" when nothing is set. | ⚠️ Waking is deliberately reachable only from an explicit user action (this route, a session create/attach, or typing into a sleeping session). No watcher, dropped-session handler or boot-recovery path may wake a host, or a suspended machine would be woken again seconds after every suspend; `test/remote-wake.test.ts` pins that as an import fence around `src/remote-wake.ts`. ### Response All three nest the wait result under `data.wait`, so one client helper works against any of them: ```json { "success": true, "data": { "sessionId": "28325fd3-caa7-4178-82bf-87dfebf0f464", "status": "idle", "limitPaused": false, "wait": { "signal": "stop", "until": ["stop", "idle", "exit"], "timedOut": false, "immediate": false, "ended": false, "aborted": false, "waitedMs": 8421, "timeoutMs": 60000 } }} ``` `POST .../input` returns the same `wait` object alongside `delivered`, `duplicate`, `status` and `limitPaused`. `POST .../input` **without** `wait` is unchanged and still returns `{"success": true, "data": {}}`. ⚠️ `delivered: false` has **two** meanings, and they must be told apart by `duplicate`: with `duplicate: true` the input was suppressed as an already-applied redelivery (harmless, the turn it refers to may be long over), while with `duplicate: false` the **write failed** (typically no PTY behind the session). A client that reads `delivered === false` as "duplicate" silently treats a failed send as a success. | Field | Type | Meaning | |-------|------|---------| | `wait.signal` | signal \| `null` | the signal that fired (`/wait` and `/input` only) | | `wait.until` | array of signals | what the server actually waited on, after narrowing the default set for the session's mode (`/wait` and `/input` only) | | `wait.matched` | boolean | the string appeared (`/wait-output` only) | | `wait.match` | string | the literal that was searched for (`/wait-output` only) | | `wait.snippet` | string \| `null` | bounded window of output around the match, blank runs collapsed for readability (`/wait-output` only) | | `wait.timedOut` | boolean | the wait hit its timeout. Still a `200` | | `wait.immediate` | boolean | the condition already held at call time, so nothing was waited for (`waitedMs` is 0) | | `wait.ended` | boolean | the session went away (deleted or torn down) before the condition was met | | `wait.aborted` | boolean | the client hung up, so the waiter was released without resolving — and by that definition a client never reads `true`. When the **server** abandons a wait itself (send-and-wait against a session with no PTY), it answers in about a millisecond with `ended: true`, `delivered: false`, `duplicate: false` and `aborted: false`: `delivered`/`ended` carry that story, and `aborted` stays the transport flag. Present for completeness; treat a `true` as "this wait answered nothing", never as an outcome | | `wait.waitedMs` | number | wall-clock ms actually spent waiting | | `wait.timeoutMs` | number | the timeout **after clamping**, which is what was applied | | `status` | `SessionStatus` | the session's status after the wait, so a caller that timed out still learns where things stand | | `limitPaused` | boolean | the session is paused on a usage limit and will emit nothing until its reset, so a timeout here is expected rather than a stall worth retrying hard | Read the outcome by discriminator, in this order: 1. `wait.signal !== null` (or `wait.matched === true`): the thing happened. 2. `wait.timedOut`: a poll boundary. Loop again. 3. `wait.ended` or `wait.aborted`: the wait answered nothing, because the session is gone or was never running. Re-check the session instead of looping. `wait.immediate` is not a fourth outcome: it rides along with the first one and means the condition already held at call time, so nothing was actually waited for. If that is not what you meant, you wanted `fresh=1` or the send-and-wait form. Note that `{"signal":"exit","immediate":true}` on a session you just created is the not-started-yet case, not a crash. **The timeout is clamped, so read it back.** A request for 1800000 ms is silently reduced to the server's ceiling (600000 ms by default, operator-tunable), and a request for 1 ms is raised to 1000 ms. `wait.timeoutMs` is the value that was applied. Without checking it, a caller that asked for 30 minutes and got 10 will read the timeout as "the worker is wedged" and kill a session that was working fine. ### Errors | `errorCode` | HTTP | When | |-------------|------|------| | `INVALID_INPUT` | 400 | unknown `until` / `wait` token; `stop` or `blocked` requested explicitly on a mode that can never emit it (`stop` without hooks, `blocked` without hooks or a screen-read dialog; the message names the mode); `regex=` on `/wait-output`; `match` outside 1 to 200 chars; a non-numeric `timeout` | | `NOT_FOUND` | 404 | no such session, or one this caller does not own | | `SESSION_BUSY` | 409 | this session's waiter cap is full | | `RATE_LIMITED` | 429 | a per-owner or process-wide waiter cap is full. Retry later; the session you named is not the problem | The two capacity codes are deliberately different. A process-wide cap reported as `SESSION_BUSY` would tell the caller to switch sessions, which cannot help. The error message names the cap that was hit. ⚠️ A `401` is **not** in this table and is not an envelope at all (see [Response envelope](#response-envelope)). It matters most here: a polling loop that pipes each wait straight into `jq` fails with a parse error on every iteration against a password-protected server, which reads as "the wait endpoints are broken". Check the status first. The per-session cap is a **combined** budget: signal waiters and output waiters count against the same 16, not 16 of each. An abandoned request no longer holds its slot, because the routes release the waiter when the client disconnects, but a client that opens many concurrent waits against one session will still hit the cap. ## Terminal capture (`GET /api/v1/sessions/:id/terminal`) What a session's terminal shows, for a client to replay: `data.terminalBuffer`, with `source` (`mux-visible`, `mux-full-history` or `history`), `truncated`, `truncationReason`, `fullSize`, `historyLinesCut` (a `lines=` bound left older lines in tmux, so the remainder is more than `fullSize - retainedBytes`), and `captureCols`/`captureRows` when the pane's geometry was read. `paneHistoryLines` (present when the body is a pane capture whose geometry was read, like `captureCols`/`captureRows`, and absent for a remote SSH session, whose local pane cannot see the remote history) is the number of scrollback rows tmux holds above the visible frame, which is the most a `full=1` request can add. `truncated` describes the byte stream instead: for a pane with `paneHistoryLines: 0` (a fullscreen CLI in the alternate screen) the bytes a `tail` cut dropped are earlier repaints that no request returns. The capture runs synchronous tmux calls on the server; the `Server-Timing` header reports `capture`, `prepare` and `total`. | Query | Meaning | |---|---| | `full=1` | tmux's scrollback, not only the visible frame (`source: 'mux-full-history'`), ending with a relative cursor move back to the pane's caret. | | `tail=` | Keep the newest `` of the result (`truncationReason: 'tail'` when it cut). | | `lines=` | With `full=1` only: read at most `` lines of tmux history above the visible frame. An integer of at least 1, clamped to the configured history limit; absent or malformed, the whole limit (100,000 lines by default), as before. When a bound below the configured limit left older lines in tmux (its `history_size` is deeper), `truncated` is true with `truncationReason: 'tail'`; a byte cap still reports `'capped'`. Without it a full capture reads all of that history before `tail` cuts it, so a client that keeps a fixed number of lines (the tile grid sends its xterm's scrollback plus its rows) should send it. | | `alt=recording` | With `full=1` only: if the pane is in the alternate screen (a nested tmux, where tmux keeps no history, or any full-screen app such as vim or less while it is open), answer with the visible frame over the byte recording instead (`source: 'mux-visible'`, `tail` applied as without `full=1`). Shells' automatic loads send it. | ## Experimental bounded agent inspection and guarded input **Experimental:** `codeman agent inspect`, `stage` and `submit`, and their `/api/v1/sessions/:id/inspect|stage|submit` routes (including `/api` aliases), are labeled experimental, so the versioning policy's rule for experimental surfaces applies (`docs/versioning-policy.md`, "Experimental agent coordination"): they are not part of the stable contract. Their commands, flags, paths, payloads and behavior may change or be removed in a minor or patch release. Pin a compatible Codeman version when automating them. Authentication and session ownership checks still apply. **Opt-in:** all three routes answer `403 FORBIDDEN` while the synced `agentCoordinationEnabled` setting is off (the default; App Settings, Cross-session features, **Experimental agent coordination**), with a message naming it. The check runs before session lookup and body validation, and reads the saved setting on every request, so turning it on needs no restart. `codeman agent inspect|stage|submit` print that refusal verbatim (exit 1). These authenticated routes use the standard envelope and session ownership checks. `/api/v1` aliases `/api`; an inaccessible session returns `404 NOT_FOUND` before any transcript, pane or receipt lookup. | Endpoint | Parameters/body | Result | | --- | --- | --- | | `GET /api/v1/sessions/:id/inspect` | `maxBytes=8192` (integer 1–65536), optional opaque `since` UUID | `source`, `identity`, observed `activity`, bounded UTF8 `evidence`, `truncated`, `incremental`, `cursor`, `maxBytes`; native results also include `omittedBeforeBytes`, `scannedBytes`, optional `blockedReason` | | `POST /api/v1/sessions/:id/stage` | `{ "message": "printable single line" }` | `{ stageId, sha256, expiresAt, sessionId, submitted:false }`; types without Enter | | `POST /api/v1/sessions/:id/submit` | `{ "stageId": "UUID", "confirmSha": "64 lowercase hex characters" }` | `{ stageId, sessionId, submitted:true }`; sends one conditional Enter | Inspection initially supports native local Codex JSONL with strict conversation attribution, using the session's effective `CODEX_HOME`, explicit resume identity or unique managed originator. It never selects an arbitrary newest cwd match. The initial read selects recent complete records; `omittedBeforeBytes` explicitly labels its tail omission. Cursor reads consume only complete appended records, keeping UTF8/JSONL boundaries. Evidence bytes exclude response metadata and JSON escaping. Record scans cap at 256 KiB plus bounded identity anchors; attribution separately caps at 8192 directory entries, eight directory levels, 8 MiB of negative-filter prefix reads and 1 MiB of candidate metadata reads (including final identity verification). The negative filter reads 1 KiB of each rollout first and 4 KiB only when that window holds no complete `originator` field. Once a rollout is attributed, later calls re-verify only that file (same file identity, same strict metadata) and rescan only the `sessions/YYYY/MM/DD` directories not older than its own, for a second candidate such as a codex `/new`. A file that no longer verifies falls back to one full walk; a verified file whose incremental scan finds a second candidate or runs out of budget answers unattributed without one, since the full walk reads a superset of that scan. So the caps bound the first call, not every call. An unexpected read error is `422 OPERATION_FAILED` (logged with the session id and error code only), not a retryable `409`. Unreachable workspace/home/metadata directories are probed with a bounded past-cap probe before canonical reads and remain unattributed. A full incremental page can be continued with its cursor; only a first pending record too large for the budget reports an output-budget block. Oversized/unfinished records report `blockedReason`; incremental reads do not silently skip them. Other modes, remote/container sessions, and unprovable native attribution return `source:"pane-snapshot"`, `incremental:false`, `cursor:null`; these snapshots can repeat evidence. Attribution identifies a file, not an authoritative current-thread signal. A configured launch resume and a new owned rollout conflict and force fallback. An in-CLI `/resume` of an existing foreign-originator thread may leave no new owned rollout; launch metadata cannot detect that switch. Check the returned conversation identity and do not use these observations as proof of the active thread, task completion or authorization. Opaque server-side cursors bind caller, session, pane generation, conversation, opened-file identity, byte offset and bounded content anchors. Terminal resizes do not change this inspection scope. Each owner and session keeps its newest 8 cursors (a retry of the last call still works; older ones are retired), at most 1024 live cursors are retained in all, each for 15 minutes; restart, eviction or expiry requires a fresh tail baseline. Replacement, truncation, detected rewrites, reassociation, foreign cursors or loss of the attributed source return `409 CONFLICT`. No transcript filesystem path is returned as cursor/identity metadata; evidence retains the owner's native record content, which can itself contain paths. Guarded input initially requires a local characterized Codex composer, one unwrapped visible line, a recognized footer and a cursor at its exact end. The composer glyph and footer are the CLI registry's `capabilities.workDetect.promptGlyph` and `composerFooterLine`; a CLI that declares no `composerFooterLine` is refused. Unknown/working/modal/shell/remote/container composers fail closed. Stage accepts at most 8192 UTF8 bytes, no C0/C1/DEL controls, multiline text, or trailing whitespace (the existing mux transport trims it). Before typing, it checks the actual captured pane width and rendered composer indentation, refusing any message that would reach the right edge or wrap. Every declared Codex effort word plus `default` is recognized. Its two-minute receipt stores only hashes/ephemeral identities in a bounded in-memory store (1024 live receipts), never persisted prompt content. Stage refuses, before typing anything, a message that would change what Enter does rather than what it sends, from the registry's `composerCommandPrefixes` and `composerCompletionSigils`. For codex: a message starting with `/` (a slash command; codex runs it, or with its command list open runs the highlighted one) or `!` (a shell command), checked after any leading whitespace, and a message whose last word starts with `@` (file and mention search) or `$` (skills; every `$` word, since whether codex opens that list depends on its skill catalog). Those popups follow the caret, which stage leaves at the end of the message, so a `@` or `$` word earlier in the message is accepted. Send slash and shell commands through `POST .../input` (`codeman agent send`). Independently, a frame with a suggestion popup in the block directly above the composer (the registry's `composerPopupLine`) is refused at stage and submit, since codex draws every popup there and leaves the footer below the composer unchanged. A receipt is issued only for a capture that shows exactly the message with the caret after it. Codex repaints its composer 30 to 60 ms after the keys arrive, so after typing stage re-reads the pane every 50 ms for up to one second and takes the first frame with the same pane generation, the input revision its own write moved and a composer whose SHA-256 equals the message's. Each capture reads the screen and the caret in one tmux invocation. A frame showing another writer's input, a new pane lifecycle or a new geometry ends the wait at once. Every refusal after the keys went out (typing failed or was ambiguous, the pane changed, or no frame verified in time) is a `409` that says the message may already be in the composer: inspect the pane and follow the stranded-draft recovery (Ctrl+U) in the skill reference before staging again. While a guarded submit holds the input lease (the few milliseconds of its conditional Enter), input that reaches the session is held, not refused, and is written in arrival order as soon as the lease is released, whatever the submit's outcome. That covers terminal WebSocket keystrokes (each frame is acknowledged only once it is written, so a frame typed during the lease lands before the one typed after it), Shift+Enter and Ctrl+Enter from `POST .../send-key`, approval answers and Codeman's own managed writes. A held write that is still being sent holds everything behind it, with one exception: when the hold queue is full (256 inputs or 1 MiB), a managed write (`writeViaMux`, which `POST .../input` uses for a plain prompt or `useMux`) is not held and that route falls back to a direct write, which is refused during the lease itself but can land ahead of input still being written after it. A tagged `POST /api/v1/sessions/:id/input` (one carrying `clientId` and `seq`) instead answers `409 CONFLICT` ("A guarded submit is in progress on this session; nothing was written") for that span, the lease plus the held input's drain, rather than a `200` for bytes that were never written; it keeps its seq, so the browser's durable queue, whose POST fallback sends one frame at a time and keeps it on a `409`, delivers it on the next retry. Untagged input to that route is held like a WebSocket frame, because its senders (the phone accessory keys, the Stop button) never read the answer. A lease holds at most 256 inputs or 1 MiB; past that a WebSocket is closed with code `1013` and nothing later from it is applied, so the browser reconnects and resends every unacknowledged frame in order, and `send-key` and an untagged raw input answer `409 CONFLICT`. Direct auxiliary key operations participate in the same pending-write and input lease checks. Complete allowlisted focus/mouse reports and empty writes preserve input revision; mixed or unknown sequences remain keyboard-bearing. Exact composer/caret checks also reject a mouse-moved caret. Submit consumes an owned receipt before awaited delivery, verifies the caller, confirmation hash and expiry, then waits until at least 250 ms have passed since the staged keys went out (codex reads keys typed in one burst as a paste and, for 120 ms after one, turns Enter into a newline instead of a submit), then verifies lifecycle/input/geometry revisions and exact composer/caret, then uses a session-level conditional Enter dispatch with a temporary input lease. Competing Codeman writes cannot enter during that dispatch. Managed restarts and intervening browser/API/auxiliary input invalidate receipts independently of pid reuse or restored visible text. Direct tmux edits remain outside Codeman's serialization. Failed, ambiguous, expired, changed or repeated submits return `409 CONFLICT`; every owned attempt consumes the receipt. Neither submit nor its error recovery automatically replays Enter. Success acknowledges delivery of one Enter, not native acceptance or task completion. Invalid schema input returns `400 INVALID_INPUT`. ## The `codeman agent` CLI (client over these endpoints) `codeman agent ls|spawn|send|wait|read|inspect|stage|submit|interrupt|rm|post|inbox|ack` (`src/cli-agent.ts`) is the command-line client for the endpoints above, for agents the skill does not reach (a pi or shell session, a machine without `jq`). It uses the guarded endpoints above for `inspect`/`stage`/`submit`, and adds no route for the other verbs: `spawn ` is `POST /api/v1/quick-start`, and `spawn` with no case (the caller's cwd) or with `--working-dir ` (an existing local directory; combining it with a case, or an empty or whitespace-only value, refuses with exit 4 before any request; so does a cwd that resolves to the home directory, since the server would install its hooks there, so HOME needs an explicit `--working-dir ~`) is `POST /api/v1/sessions` with `workingDir`, `mode`, `name` and `parentSessionId` followed by `POST /api/v1/sessions/:id/interactive` or `/shell` (the mode's `capabilities.startMode`), never retried; either form then runs `wait-output` on the mode's `capabilities.composerReadyMark` from the CLI registry where it declares one. `send` is `POST …/input` with `clientId`+`seq` (and `wait`/`waitTimeout` for `--wait` / `--until `; `delivered:false` without `duplicate` and `wait.ended` both exit 3 — the CLI never reports a dead worker as done), `wait` is `GET …/wait` (`--until`) or `GET …/wait-output` (`--match`, `from=buffer` by default), `read` is `GET …/last-response` or `GET …/terminal?tail=`, `interrupt` is `POST …/input` with a bare `\u001b`, `rm` is `DELETE …/sessions/:id`, and the inbox verbs map one to one onto the [agent inbox](#agent-inbox) routes (`post` → `POST …/inbox`, `inbox [id] [--wait]` → `GET …/inbox[?wait=]`, own inbox by default, `ack ` → `POST …/inbox/ack`); `ls` adds an `INBOX` column from `GET /api/v1/agent-inbox/summary` and drops it when that route does not answer. A fire-and-forget `send` to a sleeping wake-on-LAN host reads the route's `buffered` (own line, exit 0) and `dropped` (exit 1: the chunk is gone). An id may be the 8-character form `ls` prints, resolved through `GET /api/v1/sessions`; anything shorter refuses before any request, the same floor as `PARENT_SESSION_ID_MIN_PREFIX`. Every call carries `X-Codeman-Parent-Session`; only `spawn`'s quick-start carries `X-Codeman-Agent-Origin: codeman-agent-cli` (the agent-scratch label must never reach a request that cannot create the case directory). Basic auth comes from `CODEMAN_PASSWORD` or the data dir's `.env`. `--json` prints the envelope's `data`, except a directory `spawn`, which prints `{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed (with `ready: false`, exit 1, the created id kept so the caller can inspect it). An unknown `--mode` refuses (exit 4) before any request, for a named case too. Server-side error codes are shown verbatim (`INVALID_INPUT: until=stop …` on a hook-less mode is not hidden); exit codes are `0` ok, `1` error, `2` timeout, `3` the session exited, `4` refused by a client-side guard (for `inbox --wait`, `3` only when `GET /api/v1/sessions` no longer lists the session; a wait the server ended otherwise, on a restart or a detach, is `2`). `post` takes text that starts with `-` as it is (a markdown file often does); text that is exactly an option (`--json`, `-h`) goes after `--`. See the README section "`codeman agent`" for the guards and `test/cli-agent.test.ts` for the pinned behaviour. `codeman mcp` (`src/mcp-server.ts`) is a Model Context Protocol server on stdio over the same endpoints, for MCP clients: `list_sessions` (`GET /api/v1/sessions`), `start_session` (`POST /api/v1/quick-start` with `X-Codeman-Agent-Origin: codeman-mcp`, then `wait-output` on the mode's ready mark), `send_prompt` (`POST …/input` with `wait`/`waitTimeout`), `wait_for_session` (`GET …/wait` or `…/wait-output`), `read_output` (`GET …/last-response` or `…/terminal?tail=`), `interrupt_session` and `delete_session`. It adds no route. The URL comes from `CODEMAN_API_URL` or `--url`, never a guess; prompting, interrupting and deleting are limited to sessions the process started (or the calling session's own children) unless it runs with `--allow-any-session`. See the README section "MCP server" and `test/mcp-server.test.ts`. ## Agent inbox A per-session mailbox: another agent (or the user) posts a message, and the receiving agent reads it when it chooses to. Nothing is typed into the receiver's pane, which is the difference from `POST …/input`: a post does not start or interrupt a turn, and the text may span several lines. Design and the reasons behind each rule: [discussion #558](https://github.com/Ark0N/Codeman/discussions/558). | Method | Route | Body / query | Answer (`data`) | |---|---|---|---| | `POST` | `/api/v1/sessions/:id/inbox` | `{ text }` | `{ sessionId, message, pending, unseen }` | | `GET` | `/api/v1/sessions/:id/inbox` | `?wait=` (optional) | `{ sessionId, receiver, messages, pending, unseen, wait? }` | | `POST` | `/api/v1/sessions/:id/inbox/ack` | `{ ids: string[] }` (1 to 200) | `{ sessionId, removed, pending, unseen }` | | `GET` | `/api/v1/agent-inbox/summary` | | `{ sessions: { [id]: { pending, unseen, oldestUnseenAt, parked } } }` | A message is `{ id, from, text, createdAt, seen }`. `from` is a session id or `null` (the user); `seen` says whether the receiver had read it before this read. **Who is calling.** The server resolves the caller from the `X-Codeman-Parent-Session` header, the one `codeman agent` sends on every request, with the same resolver as session lineage: a live session the caller can see, with the same owner as the receiving session, matched by exact id or a unique prefix of at least 8 characters. Anything else counts as no session. The body cannot name a sender (`InboxPostSchema` is strict). In multi-user mode that means a caller cannot claim to be another owner's session. Admins are the exception, as everywhere else: an admin can see every session, so an admin's request can act as any session of the receiver's owner. In single-user mode every local process holds the same credentials, so the header is a hint the client gives about itself, not proof. **Read until ack.** A read returns every pending message, oldest first, and removes nothing. Only `POST …/ack` removes, by id. Acking an id that is already gone is a success, so a retry after a lost response does not fail. **Seen is the receiver's.** A read marks messages seen only when the resolved caller is the receiving session itself (`receiver: true`). Any other read, a coordinator checking a worker or a request without the header, is a peek and changes nothing. `ack` from anyone but the receiver is `403 FORBIDDEN`. **`?wait=`** blocks while the inbox holds nothing **unseen**. A message that was read but not acked does not end the wait, so a receiver that keeps one pending does not spin. The value is clamped to the agent-wait bounds like `timeout` on `/wait`, a timeout is a `200` with `wait.timedOut: true`, and the waiter counts against the same per-session, per-owner and total caps (`409 SESSION_BUSY` / `429 RATE_LIMITED`). `wait` has the shape of the other wait results plus `posted` (a post released it). A session deleted during the wait answers `wait.ended: true` with an empty inbox. A detach or a server shutdown ends the wait with `ended: true` as well, so check the session list before treating the session as gone (`codeman agent inbox --wait` does: exit 3 only when the list lacks it, exit 2 otherwise). A client that hangs up during the wait marks nothing seen. A **peek** with `?wait=` returns at once for as long as the receiver has unseen mail, since only the receiver's read clears it; to watch whether a worker has read its mail, poll the summary instead. **Limits.** 200 messages, 16384 bytes (UTF-8) per message, 512 KiB of text per inbox. A message over the per-message cap is `400 INVALID_INPUT`. A post that would cross either inbox cap is `409 CONFLICT` and nothing is stored; the oldest message is never dropped to make room. Messages expire 12 hours after their `createdAt`, read or not. **Summary.** `GET /api/v1/agent-inbox/summary` lists every session the caller can see that has mail or a parked receiver: `pending`, `unseen`, `oldestUnseenAt` (epoch ms, `null` when nothing is unseen) and `parked` (the receiver itself is waiting on `?wait=` right now). Counts only, never text. It is not part of the session object, so a post does not broadcast `session:updated`. **Lifetime.** An inbox is deleted with its session (`DELETE /api/v1/sessions/:id` and every other delete path), not when a session is detached. By default inboxes live in memory and a server restart empties them. The synced setting `agentInboxPersistEnabled` (default off; Settings → Cross-session features) writes them to `agent-inbox.json` in the data dir (mode `0600`, tmp file + rename) and restores them at boot. Inboxes of sessions that did not come back are pruned only after the session restore succeeded; a session on the reboot-restore offer keeps its inbox, so a rebuild finds its mail. Switching the setting off deletes the file and keeps what is in memory. The file is never read by `/api/search`, and the file viewer refuses it. ```bash # coordinator: leave a task for a worker curl -s -X POST "$API/api/v1/sessions/$WORKER/inbox" \ -H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \ -d "$(jq -n --arg t "$(cat task.md)" '{text: $t}')" # worker: wait for new mail, handle it, then ack by id curl -s "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox?wait=300000" \ -H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" | jq '.data.messages' curl -s -X POST "$API/api/v1/sessions/$CODEMAN_SESSION_ID/inbox/ack" \ -H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" -H 'Content-Type: application/json' \ -d '{"ids":[""]}' ``` ## Prompt uploads (`POST /api/v1/sessions/:id/paste-image`) A `multipart/form-data` body with one `image` part. The file is written into the session's workspace as `/.codeman-uploads/paste--.`, and `data` carries `path` and `filename` for the client to type the path into the prompt. The folder is Codeman's own: hidden, created on first use with a `.gitignore` containing `*` (written once, never over a file already there), and cleaned up the way pasted images always were: `paste-*` files older than 7 days go in an hourly sweep, and the folder goes when the last session of that workspace is killed. Uploads made before this release sit in `.claude-images/`; that folder receives nothing new, and is swept and removed the same way for one release. A remote (SSH) session answers 400, since the file would land on the Codeman host under a path the remote agent cannot read. A Docker session of an owned case is fine, its workspace is bind-mounted at the same absolute path; an adopted container (`owned: false`) mounts nothing, so its agent can open the file only if the container itself exposes that host path. The optional query flag `?kind=video` selects a second path for a camera-roll video: the file streams to disk instead of being buffered, under a far larger per-file cap (`MAX_PASTE_VIDEO_BYTES`, 1 GB by default, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), and only video container types are accepted (mp4, webm, mov, m4v, ogv, single-sourced from the file viewer's playable set). The container is checked from the first bytes as the body arrives; a file whose bytes do not match its declared type is unlinked and answered 415, one over the cap is unlinked and answered 413, and a transfer that dies midway leaves nothing behind. Because one video can be 1 GB, `?kind=video` draws on its own rate limit, 3 uploads per minute per (IP, session), beside the 30 per minute the image path keeps; without the flag the route is byte-identical to the image-only version, same 50 MB image cap and same 30/min bucket. The response shape is unchanged (`path`, `filename`). Beyond images, the route also accepts documents a user hands an agent to read, admitted by the name's extension (the Content-Type a phone attaches to a `.md` or `.csv` is unreliable): the office formats `.pdf`, `.docx`, `.pptx`, `.xlsx`, `.zip` and the text family `.txt`, `.md`, `.csv`, `.tsv`, `.json`, `.log`, `.yaml`, `.yml`, `.xml`. A document is buffered under the same 50 MB image cap, its bytes are checked against the extension (a magic-number signature, or UTF-8 with no NUL byte for text) and answered 415 on a mismatch (for text, an `error` saying the file is not UTF-8 text, or UTF-16 text when it opens with a UTF-16 byte order mark, and to save it as UTF-8), and it is saved under a sanitized stem of its name, so `Q3 report.pdf` is findable as `paste---Q3-report.pdf`. A named type that is neither an image nor a document (a script, an HTML page, a `.sh`) is refused 400 `Unsupported file type`. That refusal is a scope choice, not a security property: `file-raw` already serves non-media text inert (`text/plain`, `nosniff`), so this just keeps the server from storing what it would serve or an agent could run, and html and svg are the ones deliberately kept out. ## New folder in the path picker `POST /api/filesystem/mkdir` with `{ path, name, sessionId?, showHidden? }` makes one folder directly inside `path`, which must be a folder the path picker may browse, and answers `{ path }`. `name` is one path segment (letters, numbers, spaces and `_ - . ~`, not starting with a dot or `~`). `400` for a cases folder (use Add Case → Create New or Clone Repo with no custom folder) or a `path` that is not a directory, `403` for a blocked tree or a `path` outside the browse roots, `404` for a `path` that does not exist, `409` when the folder already exists, and `422` when the mount under `path` does not answer. ## Rendered HTML files An `.html`/`.htm` file renders as a page in a sandbox, in two steps (`src/web/routes/html-view-routes.ts`). **`POST /api/v1/sessions/:id/html-view`** with `{ "path": "" }` (absolute, relative to the session's workspace, or `~/` for this host's home, which a Docker case refuses with `400` since its `~` is the container's; a non-empty string of at most 4096 characters without NUL or line breaks, and no other keys) answers `data: { url }`, a `/html-view//` URL. The capability grants GET access to the files under that file's own folder, so a page's relative CSS, scripts, images and data files resolve. It is held in memory only (a restart invalidates it), has a rolling TTL (`CODEMAN_WEBVIEW_CAPABILITY_TTL_MS`, default 12 h), is reused for the same folder, and is revoked on logout, admin logout and user deletion. Refusals: - `400`: an invalid body, a remote (SSH) session, or a file that is not HTML (the symlink-resolved name counts too); - `404`: no such file; - `403`: a file outside the workspace that the attachment guard blocks (a blocked tree, or `attachmentConfineToWorkspace` on), a file in a hidden directory (inside the workspace, one below the workspace root), or a file directly in a folder too broad to serve recursively: `/`, the home folder, `/tmp`, `/var/tmp`, `$TMPDIR`, the cases and user-space roots, a drive's mount point (`/mnt/d`), or any folder above one of those (`/home`, `/Users`). **`GET /html-view/:cap/*`** serves the page and its assets. It needs no cookie: the capability in the path is the credential, because the page runs in an opaque origin that never sends one. It is not under `/api`, so it has no `/api/v1` alias and no envelope. `GET` and `HEAD` only; a CORS preflight on a live capability is answered with `Access-Control-Allow-Methods: GET, HEAD`. What it serves: files under the capability's folder whose path (requested and symlink-resolved) has no dot segment and whose extension is on an asset allowlist (html, css, js/mjs, json, map, txt, csv, md, svg and common image, font, wasm, audio, video and PDF types), never outside the folder, never a blocked tree, and, for a folder outside the workspace, not while `attachmentConfineToWorkspace` is on. Every response carries `Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads` as its whole policy (never `allow-same-origin`, so the page cannot read Codeman's document, cookies or API, even opened as a top-level tab; and no fetch directive, so by the owner's decision the page may load from and send requests to any host), `Cache-Control: no-store`, `Referrer-Policy: no-referrer` and `Access-Control-Allow-Origin: *`. A `Range` request is answered with `206` + `Content-Range` (`416` past the end), like `file-raw`. Every refusal is a plain-text `404 Not found`; with a password set, an unknown or revoked capability is the auth hook's `401` instead. ## Session lineage (`parentSessionId`) A create request may name the session that spawned it, which the web UI draws as a line between the two tabs. Accepted on `POST /api/v1/sessions` and `POST /api/v1/quick-start`, either way: ```bash # as a body field -d '{"caseName":"worker-1","mode":"claude","parentSessionId":"'"$CODEMAN_SESSION_ID"'"}' # or as a header, which is what an agent driving many spawns should use: set it once # on the curl invocation and every spawn call carries it -H "X-Codeman-Parent-Session: $CODEMAN_SESSION_ID" ``` The body field wins if both are present. The value is resolved against live sessions (exact id, or a unique prefix of at least 8 characters) and must belong to the same owner as the session being created. **It cannot fail your spawn.** An unknown, stale, foreign or malformed value is silently dropped and the session is created without lineage — never a `400`. It is also pure decoration: it confers no permission, and a child is unaffected by its parent exiting. It appears on session state as `parentSessionId` (absent when unresolved) and survives a server restart. ## Pane directory (`paneCwd`) Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `paneCwd`, the directory the session's local tmux pane is in now (`#{pane_current_path}`), which the web UI uses as the base for a relative path printed in the terminal. It is never restored after a restart (it is written to `state.json` with the rest of the session state but not read back), absent for remote, Docker, adopted, direct-PTY and rediscovered sessions and for a session split into several panes, and never a confinement root: file routes stay confined to `workingDir`. tmux reports the resolved directory, so for a workspace reached through a symlink a directory at or under the resolved workspace is reported in `workingDir`'s spelling. ## Live worktree (`liveWorktree`) Session state (`GET /api/v1/sessions`, the `session:updated` event) may carry `liveWorktree`, the linked git worktree the session's pane is in now, for example after Claude Code's EnterWorktree moved the running session: ```json "liveWorktree": { "name": "feature-a", "path": "/home/me/repo/.claude/worktrees/feature-a" } ``` It is derived from `paneCwd`, so it is absent wherever that is, and it is only ever a folder strictly below `workingDir`: a worktree elsewhere is never reported. `path` is in `workingDir`'s spelling. Like `paneCwd` it is never restored after a restart and never a confinement root. `GET /api/v1/sessions/:id/files` takes an optional `root=live`, which starts the listing, or a `q` search, at that worktree instead of at `workingDir`. The response's `root` names the folder that was walked: it stays `workingDir` when the session is in no worktree, when the worktree has gone, and when the folder no longer resolves inside `workingDir`. A `path` in the response stays relative to `workingDir` either way, so it works unchanged on `file-content` and `file-raw`, while a `q` containing `/` is matched against paths relative to the folder walked. The response also carries `worktree`, the same `{ name, path }`, whenever the session has a `liveWorktree`, whichever folder was walked and with or without `root=live`. The walk lists at most 5,000 files and folders and opens no more folders once it has read 250,000 directory entries (the folder that crosses that line is read whole). It sets `truncated` when it stops with anything left, which includes a folder it never opened, even if that folder would have turned out to be empty. It is a per-directory round-robin (breadth-first): each folder found so far gets an equal share of what is left before any of them gets more, and a folder's own files count as one more share beside the subfolders it has just found, so one huge folder cannot use up the listing limit before its later siblings or its own subfolders are read, and the `matches` of a `q` search come back shallow-first. A folder made of thousands of small subfolders can still use most of the limit one level down, and folders that add up to the read limit can leave later ones unread. A folder below the start of the walk that does not answer a bounded probe (a mount that stopped responding) is not read either, and counts as left over the same way. In the tree, a directory node the walk stopped short of for either reason also carries `incomplete: true`: its `children` hold what was listed before it stopped, which can be nothing. The key is absent on every other node. ## Session model (`displayModel`) Session state (`GET /api/v1/sessions`, the `session:updated` event) carries the model a session runs as far as the server knows it, for the web UI's session headers: ```json "displayModel": { "model": "qwen3.8-27b", "source": "screen" } ``` `source` is where it came from, strongest first: | `source` | Meaning | | ----------------- | ----------------------------------------------------------------------------------------------------- | | `custom-endpoint` | The session is pointed at a Custom Model Endpoint Profile; its `modelId` answers, whatever the CLI prints. | | `statusline` | Claude's statusLine exporter reported it (`model.display_name`); follows an in-session `/model`. | | `screen` | Read off the CLI's own footer (`capabilities.modelDetect`, today dsh and codex); follows a switch. | | `config` | What the CLI's own config pins for the session (`capabilities.modelDetect.configResolver`, today dsh-TUI's route), while its screen names none. | | `launch` | What the session was launched with (`--model`, the app-wide default, `Config.model`); nothing has reported since. | Between `statusline` and `screen` the newest report wins. The field is absent when no model is known (a shell, a CLI that reports none and was launched without one). `model` is display text from a pane or a CLI report: control characters are stripped and it is at most 64 characters, but treat it as untrusted text. A `statusline` or `screen` value is persisted and restored after a server restart until the next report replaces it; a `config` value is read again at every pane start, attach and relaunch instead. ## Prompt cache (`promptCache`) Claude-mode session state (`GET /api/v1/sessions`) carries the main conversation's prompt-cache state as Claude Code last reported it on the statusline (v2.1.251+), with a change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): ```json "promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 } ``` | Field | Meaning | | --------------------- | --------------------------------------------------------------------------------------------------------- | | `warm` | Whether the cached prefix is still within its TTL. The only field always present. | | `ttl` | `5m` or `1h`; absent when Claude reported neither. | | `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | | `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | | `misses` | Requests that re-processed content the cache already held. | | `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | | `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | | `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | | `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. | The whole object is absent until the first report, and restored after a server restart (so the readout is not blank until the pane's next render) rather than recomputed; a value whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm cache hits `expiresAt`, so the cold flip arrives on its own. A post-`/clear` conversation switch drops it. Shown in Session Options → Respawn and, for sessions without a statusline of their own, as a `cache:until HH:MM` / `cache:cold` group in the terminal footer. Beside it, `statusLineExporter` says whether this launch carries the statusline exporter that sends the report, decided when the pane starts: `injected` (it does), `collection-off` (Plan Usage was off at launch), `workspace-statusline` (the workspace's `.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or `remote` (remote and Docker sessions). Absent when unknown. ## Placing a new session in a tab group (`tabGroupId`) `POST /api/v1/sessions` and `POST /api/v1/quick-start` accept an optional `tabGroupId` (1 to 100 characters): the id of a group in the caller's tab layout (`GET /api/v1/tab-layout`). The session is created as usual and placed at the end of that group in the same layout write; the response then also carries the committed layout as `tabLayout`. An id the caller's layout does not contain (a deleted group, another user's group) is ignored and the session lands where it otherwise would, never a `400`. A malformed value (`null`, an empty string, a non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. ### A case's default session group - `GET /api/v1/cases/:name/default-group` → `{ caseName, groupId, groups }`: `groupId` is a group id from the caller's own tab layout or `null` (Ungrouped), `groups` the caller's current named groups (`{ id, name }`, in layout order). A stored id whose group was deleted reads back as `null` (the read changes nothing; the next session created for the case forgets it). - `PUT /api/v1/cases/:name/default-group` with `{ "groupId": string | null }` saves it. An id not in the caller's layout is a `400 INVALID_INPUT` that leaves the old value in place. Both answer `404 NOT_FOUND` for a case the caller cannot see (the same rules as `GET /api/v1/cases`). The value is stored per owner and case, so in multi-user mode it is never shared and needs no admin role. A new session of the case is placed in that group in its creating write when nothing stronger applies; the order is: an explicit `tabGroupId`, then the session it was spawned from (`parentSessionId`), then the case default, else Ungrouped. `POST /api/v1/quick-start` uses its `caseName` (local, linked, remote and Docker cases); `POST /api/v1/sessions` uses the case whose folder `workingDir` is (a direct child of the caller's case space, a linked case's exact path, or a Claude worktree of either, `/.claude/worktrees/`); without a `workingDir` there is no case. When a case default places the session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the case forgets the value; existing sessions never move. ## Adopting a foreign tmux session A tmux session someone started by hand (`tmux new -s work`) lives on its own socket, where Codeman does not look for its own sessions. These two routes list such sessions and open one as a tab. Admin-only in multi-user mode (403), since the list shows other people's processes and adopting a shell is arbitrary execution as the server account. `GET /api/v1/mux/foreign` lists candidates. Local sessions are always scanned, and cached for 5 s. `?docker=1` and `?remote=1` add the containers behind the saved Docker cases and the saved ssh hosts, one `docker exec` or ssh connection per target, so the browser sends them only on an explicit scan. `?force=1` skips the local cache. ```json { "sessions": [ { "id": "fa9b76d36", "location": "local", "socketPath": "/tmp/tmux-1000/default", "sessionName": "work", "windows": 1, "attached": false, "createdAt": 1793931275000, "mode": "claude", "command": "claude", "workingDir": "/home/me/project", "adoptedBy": "961d0a82-6888-425d-a3ee-dab38ecbb598" } ], "scannedAt": 1793931277916, "notes": ["remote Build box: unreachable, or no tmux"], "pollIntervalMs": 8000, "canScanWide": true } ``` `hostId`, `hostLabel` and `containerName` appear on a docker or remote candidate. `adoptedBy` names the session already wrapping that target. `notes` says why a location gave nothing, each opening with where it came from (`local`, `docker `, `remote