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.
- 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/v1keeps 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 (rewriteApiV1Urlinsrc/web/server.ts).
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 theGET /ws/sessions/:id/terminalWebSocket 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.
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.
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.
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.
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.
| 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.
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.
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.
| 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).
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.
| 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.
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:
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.
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.
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.
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.
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:
wait.signal !== null(orwait.matched === true): the thing happened.wait.timedOut: a poll boundary. Loop again.wait.endedorwait.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.
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.
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.
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.
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? }.contextis the ANSI-stripped visible pane frame;optionsis present only when the dialog's numbered choices parsed confidently;acknowledgedAtmarks an item a human has already looked at (see/viewedbelow) 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 asresolved_in_terminalinstead of being returned (only items whose original frame parsedoptionscan be dropped this way, so an unreadable capture keeps the item).POST /api/v1/approvals/:id/answerwith{ action: 'approve' }(sends the digit1),{ action: 'deny' }(sends Esc),{ action: 'option', option: n }(sends the digit; accepted only whennis among the item's parsedoptions), or{ action: 'text', text }(idle prompts only; submits the line as a prompt).404 NOT_FOUNDwhen the item is no longer pending,409 CONFLICTwhen the dialog left the screen or another actor answered first,422 OPERATION_FAILEDwhen the session refused input.POST /api/v1/approvals/:id/dismissremoves 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.404for 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).
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 withupdatedAt: 0; nothing is persisted by reads.PUT /api/v1/sessions/:id/intentwith{ goals }(<= 8192 chars, strict schema) replaces the goals text and answers the updated profile.400 INVALID_INPUTon over-long or unknown fields.DELETE /api/v1/sessions/:id/intent->{ deleted: boolean }forgets the case's profile entirely.POST /api/v1/sessions/:id/readmymindpredicts 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,rejectedup 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_INPUTotherwise); one prediction in flight per session (409 CONFLICT); predictor failures answer502 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.
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? }.reasonisdisabled(setting off),no-credentials(nobody signed in to Claude Code on the server),expired(the access token elapsed; running any Claude session refreshes it) ormalformed. 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:4003disallowed Host/Origin,4004unavailable (reason in the close reason),4008too many concurrent streams. Streams are capped in count and length (src/config/voice.ts).
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.
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.
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.
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.