Skip to content

Latest commit

 

History

History
580 lines (480 loc) · 32.9 KB

File metadata and controls

580 lines (480 loc) · 32.9 KB

HTTP API Reference

Codeman's HTTP API is a stable contract as of 1.0 — see 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:

{ "success": true, "data": <payload> }

data is the endpoint's payload (object, array, or value). Endpoints with no payload return { "success": true, "data": {} }.

Error — HTTP 4xx/5xx:

{ "success": false, "error": "human-readable message", "errorCode": "NOT_FOUND" }

ApiResponse<T> 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), and the GET /ws/sessions/:id/terminal WebSocket upgrade.

The agent wait endpoints 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
NOT_FOUND 404 Resource does not exist
SESSION_BUSY 409 Session is busy
CONFLICT 409 Conflicts with current state (e.g. already running)
ALREADY_EXISTS 409 Resource already exists
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.

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 and blocked fire only for claude sessions. Both come from Claude Code hooks, and no other mode installs them: shell runs no agent, and the external CLIs (opencode, codex, gemini, antigravity, pi) render their own TUIs and post no hooks. For every non-claude mode only idle, working and exit are accepted, and of those only exit is dependable: see the caveats under Signals before building on idle. Requesting stop or blocked explicitly on such a session is a 400; 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. shell: once only, ~500 ms after start, and never again. External CLIs: not guaranteed (they render their own TUIs and readiness is output stabilization)
working the session's own working event claude only in practice (spinner and work-keyword detection are Claude output formats)
stop the Claude Code stop hook, the definitive end-of-turn signal claude only
blocked a permission_prompt or elicitation_dialog hook claude only, and 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. Measured: a session parked on the trust dialog emits no further idle, so it is the startup transition, not the dialog, that produces the false success below.

⚠️ 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.

⚠️ 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 ❯ prompt of its own. Send-and-wait posted at that moment types the prompt into the dialog, where the \r never gets past it, while the session's startup idle lands inside the wait window: the wait resolves on idle in a couple of seconds with timedOut: false, which looks exactly like a completed turn.

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 (trust matched → send \r → wait for bypass again). Do not probe trust first and Enter blindly: the dialog text stays in the terminal buffer for the life of the session, so a trust probe with from=buffer keeps matching on every later run and the Enter lands in a ready composer. A worked version is in extending-codeman.md.

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 400s, 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
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.

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.

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 400s, 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.

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.

Response

All three nest the wait result under data.wait, so one client helper works against any of them:

{ "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 installs no hooks (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). 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.

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:

# 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.

Approvals Inbox

Cross-session queue of prompts waiting on a human (permission dialogs, AskUserQuestion questions, idle prompts). Claude-mode sessions only; items are in-memory (a server restart drops them; the next prompt re-fires the hook). Design: approvals-inbox-plan.md.

  • GET /api/v1/approvals{ approvals: ApprovalItem[] }, oldest first, ownership-scoped in multi-user mode. ApprovalItem: { id, sessionId, sessionName, kind: 'permission'|'question'|'idle', createdAt, toolName?, toolSummary?, message?, cwd?, context?, options?: {n, label}[], acknowledgedAt? }. context is the ANSI-stripped visible pane frame; options is present only when the dialog's numbered choices parsed confidently; acknowledgedAt marks an item a human has already looked at (see /viewed below) and tells clients not to re-arm its tab alert. Listing also runs a staleness sweep over the caller's own items: the pane is re-captured, and an item whose dialog no longer parses is resolved as resolved_in_terminal instead of being returned (only items whose original frame parsed options can be dropped this way, so an unreadable capture keeps the item).
  • POST /api/v1/approvals/:id/answer with { action: 'approve' } (sends the digit 1), { action: 'deny' } (sends Esc), { action: 'option', option: n } (sends the digit; accepted only when n is among the item's parsed options), or { action: 'text', text } (idle prompts only; submits the line as a prompt). 404 NOT_FOUND when the item is no longer pending, 409 CONFLICT when the dialog left the screen or another actor answered first, 422 OPERATION_FAILED when the session refused input.
  • POST /api/v1/approvals/:id/dismiss removes the item without keystrokes.
  • POST /api/v1/approvals/session/:sessionId/viewed{ sessionId, acknowledged: itemId | null }. Marks the session's pending idle item as seen by a human (the web UI calls it when you open the session's tab): the item stays pending and answerable, but stops arming the yellow tab alert on every client, including after a reload. Permission/question items are never acknowledged this way, since looking at a dialog does not answer it. 404 for an unknown or inaccessible session; acknowledging twice is a no-op (acknowledged: null).

SSE events: approval:pending (full item), approval:updated (context/options re-captured, or the item acknowledged), approval:resolved ({ id, sessionId, kind, resolution } with resolution one of answered | resolved_in_terminal | superseded | session_ended | dismissed | expired).

Read My Mind intent profiles

Per-case profiles of what the user is trying to accomplish: user/agent-stated goals plus the user's recently submitted prompts, captured from the Claude session transcript while the opt-in readMyMindEnabled setting is on (default OFF). Keyed by owner + workingDir, so the profile survives /clear, respawns, and session churn. Stored in ~/.codeman/intents.json (mode 0600); never fed into /api/v1/search. Design: readmymind-plan.md; user guide: readmymind.md.

  • GET /api/v1/sessions/:id/intent -> { intent: IntentProfile } for the session's case. IntentProfile: { key, workingDir, updatedAt, goals, recentPrompts: { ts, sessionId, text }[] } (prompts oldest first, FIFO cap 50, each <= 500 chars). A case with nothing recorded answers an empty profile with updatedAt: 0; nothing is persisted by reads.
  • PUT /api/v1/sessions/:id/intent with { goals } (<= 8192 chars, strict schema) replaces the goals text and answers the updated profile. 400 INVALID_INPUT on over-long or unknown fields.
  • DELETE /api/v1/sessions/:id/intent -> { deleted: boolean } forgets the case's profile entirely.
  • POST /api/v1/sessions/:id/readmymind predicts the user's next prompt: a one-shot model call over the intent profile plus live session signals (pending approval dialog, transcript tail, git state, run-summary events, sibling sessions). Body is optional; the rethink flow passes { steer?, rejected? } (strict schema: steer <= 2000 chars, rejected up to 10 strings <= 1000 chars). Answers { suggestions: { prompt, why, kind }[], durationMs } with 1-3 suggestions (kind: continue | verify | redirect; prompts are single-line). Claude-mode sessions only (400 INVALID_INPUT otherwise); one prediction in flight per session (409 CONFLICT); predictor failures answer 502 OPERATION_FAILED. Takes 5-90 s and costs real tokens. Suggestions are only ever returned, never sent: submitting one is the caller's explicit act.

All four enforce session ownership in multi-user mode; a foreign session id answers 404 NOT_FOUND (no existence leak), and profiles of two owners of the same directory are distinct by construction.

Voice dictation

Browser dictation transcribed through this server's Claude Code login, i.e. the same speech-to-text service the CLI's own /voice mode uses. Gated on the synced claudeVoiceEnabled setting (default OFF). Design: claude-voice-plan.md.

  • GET /api/v1/voice/status -> { available, reason?, subscriptionType?, expiresAt? }. reason is disabled (setting off), no-credentials (nobody signed in to Claude Code on the server), expired (the access token elapsed; running any Claude session refreshes it) or malformed. The OAuth token itself is never returned by this or any other endpoint.
  • GET /ws/voice/stream?language=&keyterms= (WebSocket, not under /api) relays one dictation. Client sends binary frames of signed 16-bit little-endian PCM, 16 kHz mono (<= 64 KB per frame), plus JSON control frames {"t":"finalize"} (ask for the final transcript) and {"t":"stop"}. Server sends {"t":"ready"}, {"t":"transcript","text","final"} (each frame is the WHOLE running transcript, not a delta), {"t":"error","message"} and {"t":"closed"}. Close codes: 4003 disallowed Host/Origin, 4004 unavailable (reason in the close reason), 4008 too many concurrent streams. Streams are capped in count and length (src/config/voice.ts).

Authentication

Optional HTTP Basic (CODEMAN_USERNAME/CODEMAN_PASSWORD) → opaque codeman_session cookie. When enabled, unauthenticated requests get 401 UNAUTHORIZED; rate-limited requests get 429 RATE_LIMITED. See security-architecture.md.

SSE event channel

GET /api/events is a Server-Sent Events stream (text/event-stream); each message is event: <name> + data: <json>. The event-name registry (src/web/sse-events.ts, mirrored in src/web/public/constants.js) is part of the stable contract — event names are not renamed without a major bump. An optional ?sessions=<id,...> filter suppresses only the high-volume terminal stream; lifecycle/metadata events are delivered to all clients regardless.

sse:heartbeat (liveness)

Every 15s the server writes a sse:heartbeat frame to every connected client:

event: sse:heartbeat
data: {"t":1755100000000}

t is the server's epoch-ms timestamp at write time. The frame carries no application state and can be ignored for correctness. It exists so a client can tell a live stream from a dead one: an EventSource whose connection has been idle-closed by a proxy (or that resumed from sleep on a stale socket) keeps delivering nothing without ever firing onerror. Clients that care should treat silence longer than about three intervals as a dead stream and reconnect, which is what the bundled frontend does.

This replaced a :keepalive SSE comment, which served the same proxy-flushing purpose but is invisible to EventSource by spec and so could never be observed by a client. Consumers written against the old behavior are unaffected: EventSource dispatches only events that have a registered listener, so an unknown event name is dropped.

Consuming from JavaScript

The bundled frontend reads responses through _apiJson() (src/web/public/api-client.js), which unwraps {success:true,data}data and returns null on a non-2xx / {success:false} response. External clients should do the same: check the HTTP status (or body.success), then read body.data.