The default base URL is http://localhost:9889.
This page maps the public API by family and gives representative requests.
When cao-server is running, its generated FastAPI OpenAPI schema and schema
UI are the exhaustive contract for individual HTTP operations. OpenAPI does
not describe WebSocket behavior; the PTY WebSocket contract is documented
below.
curl http://localhost:9889/health
curl http://localhost:9889/sessions
curl http://localhost:9889/agents/providersHTTP errors use standard status codes and generally return a JSON detail
field. Authentication and network behavior depend on server configuration;
see Configuration and Security.
GET /healthreports service health. Itsfeatureslist names capabilities a peer node can rely on;delegation_ceilingmeans this node bounds created terminals by the caller's delegation ceiling and honourscaller_ceiling(see Tool Restrictions).GET /.well-known/oauth-protected-resourcepublishes OAuth protected resource metadata when applicable.
/eventsand/events/historyexpose server events./agui/v1/streamand/agui/v1/emit_uiprovide the AG-UI stream and generative UI input.
See AG-UI for enablement, event shapes, and privacy boundaries.
POST /ephemeral-agentscreates and stores an opt-in, ephemeral agent.POST /ephemeral-agents/{name}/claim?caller_id=<creator-terminal-id>claims it under a lease, rechecks policy and finalizes the profile. Its body accepts optionalidempotency_key,claim_idandmodel; per-call models refuse. It returns the claim, provider and stored tools, andprovider_auto(whether the spec declaredprovider: "auto"), or a matching launched terminal withreplayed: true.POST /sessions/{session}/terminalslaunches it withagent_profile,caller_id,providerand optionalclaim_idquery parameters. Claims are 32 lowercase hexadecimal characters.POST /sessionshas no caller or claim parameter and refuses a created, pending ephemeral name withnot_owner. Collected names getephemeral_expired(409); never-created names getunknown_ephemeral(404), including onPOST /sessions.POST /terminals/run-stepacceptsclaim_idfor a newly created terminal; combining it withreuse_terminal_idgives 422. Policy refusals use{kind: "ephemeral_policy", rule, message}.assignandhandofflaunch creator-owned ephemeral names from the creator terminal.GET /agents/profilesandGET /agents/profiles/{name}list and inspect installed profiles.GET /agents/profiles/searchranks installed, loadable profiles by capability using the same service ascao profile find.GET /agents/profiles/templateslists public template metadata (nameanddescriptiononly); internal template filesystem paths are never returned.GET /agents/profiles/templates/{category}/{name}/schemareturns a template's JSON-Schema.POST /agents/profiles/templates/validatevalidates a config object against a template's JSON-Schema without writing a profile.POST /agents/profiles/templates/previewvalidates and renders a template to Markdown without writing a profile.POST /agents/profiles/validatevalidates a finished profile's frontmatter against the profile JSON-Schema plus CAO conventions, without writing anything. This is the HTTP equivalent ofcao profile validate, and is distinct fromtemplates/validate, which checks a template config against that template's own schema. Findings are severity-tagged (errororwarning); only errors clear thevalidflag, so warnings are advisory.GET /agents/profiles/schemareturns the agent profile JSON-Schema, so a client can render create and edit forms from the server's definition instead of duplicating the field list.POST /agents/profiles/installinstalls a profile.POST /agents/profilescreates a profile in the local store from a supplied document. Named distinctly frominstall, which takes a bare profile name or an https:// URL rather than the document itself. The request carriesnameandcontent; the two identities of a profile, its storage key and its frontmattername, must agree, so a mismatch is a 400 rather than a silent rename. A conflicting name returns 409. Requirescao:writeorcao:admin.PUT /agents/profiles/{name}replaces an existing local-store profile and never creates one. A request naming a built-in or provider-managed profile returns 404 rather than writing a local file that would shadow the original. Requirescao:writeorcao:admin.DELETE /agents/profiles/{name}removes a profile from the local store. Requirescao:writeorcao:admin, the same guard as create and replace, so one credential covers the whole create/edit/delete cycle. Scopes are a flat set rather than a hierarchy, so requiring admin here would 403 a caller holding exactlycao:write. Built-ins are not deletable, for the same reason they are not replaceable.- Both write routes run the profile validator on the exact submitted document before persisting anything, so an invalid profile never reaches disk. Errors reject the request with 400 and the findings attached; warnings do not block the write and are returned in the response so a client can surface them after a successful save.
- The validator rejects non-string mapping keys. A profile is written as YAML,
which allows any scalar as a key, but the format is described by JSON Schema,
where object keys are strings. Without this rule
mcpServers: {1: {...}}validates clean and persists, then fails to load, since the model requires string keys. Note YAML also auto-types an unquoted date, so2026-01-01:is a date key rather than a string; quote such keys. - A document is rejected up front if it cannot safely be handed to the steps that
follow, on any of three grounds: how large it renders, how deeply it nests, or
whether it contains a cycle. The reason is that YAML anchors decouple a
document's rendered size from its byte count, and the schema step interpolates a
rendering of an offending value into every error message it builds. Chained
anchors multiply structure, and aliasing one large scalar multiplies content, so
a request under the 256 KB
contentcap can render to gigabytes either way. The ceilings are therefore in rendered bytes, the unit that cost is paid in: at most 1 MB, about 3.8x the largest request that can arrive and ~2060x the largest bundled profile's 485 bytes, and at most 64 levels of nesting (~21x). Exceeding either is itself an error and nothing further runs, since the later steps are what such a document is expensive in. A cycle is rejected rather than measured: it has no finite rendering, and the providers that consume a profile cannot serialize one, so accepting it would persist a document the runtime cannot install. Individual schema findings are also length-capped before they reach a response, which bounds the case where several fields each render a subtree. - Within those bounds, containers already visited are skipped, so each offending mapping key is reported once, at the first path that reaches it. Note that differs from the schema step, which does not memoize and so reports a shared invalid value once per referencing path.
- An
mcpServersentry must define eithercommand, for a server CAO launches, orurl, for a remote one whosetypenames its transport. The schema previously requiredcommandunconditionally, which made the write routes reject url-based servers that the runtime accepts and passes through to the provider unchanged. An entry defining neither is still rejected.urlis the spellingresolve_mcp_server_configdocuments; an entry naming its endpoint under any other key satisfies neither branch and is rejected. - Every 400 from the profile write and source routes uses one
detailshape,{"message", "errors"}, so a client never has to switch on the type ofdetail.errorsis empty for a failure that is not attributable to a field, but the key is always present. This covers rejected names as well as schema findings. 404 and 409 keep FastAPI's conventional bare-stringdetail, since the status code already discriminates and there are no findings to attach. GET /agents/profiles/{name}/sourcereturns a profile's document exactly as stored. Use this, notGET /agents/profiles/{name}, when the document is going to be edited and written back: that route returns the resolved profile, having applied${VAR}substitution from the managed environment file to the raw text before parsing. Round-tripping a resolved document through a write would persist substituted values into a plaintext profile. Requirescao:read,cao:write, orcao:admin, the same guard the profile reads beside it now carry. Gating matters at least as much here as on the parsed route, because this one returns the stored bytes verbatim from every configured store, including documents that fail to parse.- Template validation and preview require the selected template to include a
schema.jsonfile. /agents/providersreports provider availability./settings/*exposes supported agent-directory, skill-directory, and memory settings.
See Agent Profiles and Configuration.
/skills/{name}retrieves an installed skill.
See Skills for discovery, installation, and catalog behavior.
/sessions*creates, lists, inspects, and deletes sessions./sessions/{session_name}/terminals*creates and lists session terminals. The list is ordered oldest-first, and index 0 is the session's conductor —cao session status/listrely on that. Sort client-side onlast_activeif you need a different order./terminals/{terminal_id}*inspects terminals, sends input or keys, reads output and working-directory state, exits providers, and deletes terminals.- Terminal responses carry
delegate_tools, the terminal's recorded delegation ceiling.POST /sessions/{session_name}/terminalsrequirescaller_id(403 without it) and checks the requestedallowed_tools(client string or profile resolution) against that caller's recorded ceiling, answering403when they exceed it; the message names the parent, the child, the exceeding tools and the remedy. An operator adding a terminal to an existing session (the Web UI's Add Agent button) usesPOST /sessions/{session_name}/terminals:operatorinstead: same query parameters minuscaller_id/defer_init, an optional{"description": ...}body, and the new terminal records the profile's declared ceiling exactly asPOST /sessionsdoes. It is a distinct sub-path so no existing request can land on it by accident, and it answers400when the request also names a caller (caller_id,caller_ceiling,delegating_terminal_id, or a bodyenv_varscarryingCAO_CALLBACK_TERMINAL_ID).POST /sessions?memory_manager=truereports the sidecar's outcome on the response asmemory_manager({"state": "scheduled" | "refused", "detail"}); a primary whose ceiling does not cover the sidecar's@cao-mcp-server, fs_read, fs_listgetsrefusedand no sidecar. A create's response may carrylaunch_notes, a list of strings for the operator; today only a Kiro v3 launch that the operator'skiro.v3_allow_unverified_versionsopt-in admitted on a kiro-cli version still pending verification (provisional, #1167). It is never persisted and isnullotherwise.POST /sessionsandPOST /terminals/run-stepacceptcaller_ceilingin the JSON body for a caller on another node (remoteassign/handoff); it is rejected together with a localcaller_id, it is required when the body'senv_varscarryCAO_CALLBACK_TERMINAL_ID(the remote-assign shape), and when that callback terminal has a local row the asserted ceiling must fit inside the row's.caller_idandcaller_ceilingremain client assertions under the shared bearer (per-terminal credentials are tracked in #965); see Tool Restrictions for what that does and does not guarantee. GET /terminals/{terminal_id}/output?mode=fullreturns the StatusMonitor rolling buffer (most recentstate_buffer_maxbytes of streamed output — server setting, 32KB by default, see Configuration), not unbounded scrollback. Long sessions are truncated to the tail; use the on-disk terminal log for complete history.- Terminal creation accepts
use_worktree(bool, defaultfalse, issue #100 Phase 1): provisions an isolatedgit worktreeon its own branch instead of sharingworking_directoryas given, requiring the resolved directory to be inside a git repository. At deletion, the worktree's working-tree contents are always discarded, but the branch is only deleted if it has no unmerged commits — commit and merge/push results before the terminal is deleted if they need to be kept. See the MCPhandoff/assigntool descriptions for the full behavior. POST /sessionsaccepts optionalgroup/metadataat creation, opting a session's initial terminal into peer discovery (a mid-session worker usesPATCH /terminals/{terminal_id}/group/metadatainstead — see below).groupis an ordered, general-to-specific array (e.g.["tenant_1", "project_5"]);metadatais a free-form JSON object the running agent updates via theupdate_metadataMCP tool. Both PATCH endpoints are whole-value replace, not merge, last-write-wins under concurrent calls, and reject an omitted field with422(an explicitnull/[]/{}clears the value; omitting it does not).GET /terminals/{terminal_id}/siblingslists other terminals sharing a leading prefix ofterminal_id's owngroup, optionally narrowed bydepth; a caller can never see a wider scope than its own group, and a terminal with nogroupset finds no siblings. Session-scoped by default — results are also filtered to the caller's own tmux session unless the explicitcross_session=trueopt-in is passed. Siblingmetadatais agent-authored, untrusted content — same trust domain as an inboundsend_messagebody. Thelist_siblings/update_metadataMCP tools also require thediscoveryentry inallowedTools— a separate opt-in from orchestration tools, not bundled into@cao-mcp-server— see Tool Restrictions and Discovery Tool Coexistence.
group is an organizational label, not a security boundary. On a
default install with auth disabled, a worker already has local shell access
to this API, so group/discovery/session-scoping provide no tenant
isolation or access-control guarantee even used together — do not build a
security boundary on top of them.
Terminal identifiers used in these routes are eight-character hexadecimal strings. See Control Planes for operator-facing choices.
Terminal responses include a persistent description: string | null, independent
of metadata and group. The optional JSON description field is accepted by
POST /sessions, POST /sessions/{session_name}/terminals, and
POST /terminals/run-step. Creation replaces line separators (including CRLF,
NEL, and Unicode line/paragraph separators) with spaces, removes remaining C0/C1
controls (U+0000–001F, U+007F–009F, including interior tabs), trims outer
whitespace, and truncates to 64 Unicode code points. This counts neither bytes
nor visible grapheme clusters. An empty normalized value is stored as null.
For the first terminal of POST /sessions, omitting description uses the
supplied session name as its default description. Explicit {"description": null}
suppresses that default. Clients generating random session names should send
null; other creation routes have no session-name default.
PATCH /terminals/{terminal_id}/description replaces the value and returns the
updated terminal. With authentication enabled it requires cao:write or
cao:admin. Its JSON body must include description: an omitted key returns
422, while null, an empty string, or whitespace alone clears the value.
Editing applies the same normalization, but rejects a normalized value over
64 code points with 422 instead of truncating it. A valid body for a missing
terminal returns 404; invalid bodies may return 422 before existence is
checked.
Reusing a terminal does not update its description. Idempotent creation retries also preserve an operator's subsequent edit. A non-null normalized creation description participates in the request fingerprint: omitting it is not equivalent to supplying a nonempty description on a retry. Normalization applies to new inputs and edits; existing rows and stored fingerprints are not migrated.
Three JSON bodies carry delegation-decision fields for the model.route/
effort.route points (see Launch decisions): CreateTerminalBody
(POST /sessions/{session_name}/terminals), CreateSessionBody (POST /sessions),
and RunStepRequest (POST /terminals/run-step).
delegation_message: string | null(defaultnull) is accepted by all three bodies. If supplied, an empty string returns422; otherwise it must be a nonempty string or omitted entirely. OnCreateTerminalBody/CreateSessionBodyit is the only source the delegation decision reads — the decision runs only when it is present. OnRunStepRequestit substitutes forpromptas the decision's input when supplied, and falls back topromptwhen it is not.delegation_kind: "handoff" | null(defaultnull) is accepted byCreateTerminalBodyandCreateSessionBodyonly. Only the literal"handoff"is accepted; any other value returns422. Setting it requiresdelegation_messageto also be set andinitial_messageto be absent, or the request returns422("delegation_kind requires delegation_message and no initial_message"). Settingdelegation_messagewithoutdelegation_kindinstead requiresinitial_messageto be set withinitial_message_orchestration_typeequal to"assign", or the request returns422("delegation_message requires delegation_kind or an ASSIGN initial_message").orchestration_type: "handoff" | null(defaultnull) isRunStepRequest's equivalent ofdelegation_kind— this body has nodelegation_kindfield.orchestration_typeanddelegation_messagemust be supplied together or both omitted, or the request returns422("orchestration_type and delegation_message must travel together"). That check runs first, so supplying only one of the two fields alongsidereuse_terminal_idreturns the "must travel together"422, not the one below. Both fields combined withreuse_terminal_idreturn422("delegation fields cannot be combined with reuse_terminal_id").
POST /terminals/run-stepruns one agent step and accepts an optionaljob_id(32-character lowercase hex, e.g.uuid4().hex, generated by the caller). Supplying it opts the call into durable persistence; omitting it behaves exactly as before, with no persistence overhead.- A step-level
detail.kinddistinguishes worker failures (error-> 502), in-band provider refusals (provider_error-> 502), and timeouts (timeout-> 504); callers branch onkind, not the numeric status alone. A reused Kiro terminal (reuse_terminal_id) whose identity check is still running answersidentity_pending-> 409 withretryable: true: nothing was typed, so retry the same call once it is ready. GET /handoff-results/{job_id}retrieves the persisted record for thatjob_id.stateis one ofrunning(step still in progress),completed(result inlast_message), orerror(failure inerror_message).404means thejob_idis unknown — never sent, or already purged by the retention sweep (RETENTION_DAYS, same window as terminals and inbox messages).- Use it when a
handoffreturnedpending=truewith ajob_id: the MCP transport closed before the result arrived, but the step's outcome was written server-side before the worker terminal was torn down. Agents call theget_handoff_resultMCP tool rather than this route directly. - Scope-gated like other content-serving GETs (
read,write, oradmin):last_messagecarries raw worker output. Enforced only when an IdP is configured; a no-op on a default install with auth disabled. Treatjob_idas a capability — it is the sole retrieval credential for that output.
/terminals/{terminal_id}/inbox/messagessends and reads terminal inbox messages.
Agents normally use the in-session supervisor protocols rather than calling these routes directly.
/workflows*creates, updates, deletes, validates and inspects workflow specifications.POST /workflowscreates a Python spec (201); requirescao:writeorcao:admin. Invalid specs or refused paths return400, existing names or tier collisions409, and storage failures500.PUT /workflows/{name}replaces a Python spec (200) using a requiredexpected_hash; requirescao:writeorcao:admin. Unknown names return404, stale hashes or tier collisions409, invalid specs or refused paths400, and storage failures500.DELETE /workflows/{name}deletes a spec and its index row (200); requirescao:admin. An unknown or already-deleted name returns404; invalid paths return400.POST /workflows/validateaccepts exactly one ofpathorsource(200); requirescao:read,cao:writeorcao:admin. Thesourceform lints a Python draft without writing a file; lint or size failures are reported in the200result. Supplying both forms or neither, or source that cannot be encoded as UTF-8, returns400.
The spec-route scope checks apply when authentication is enabled.
POST /workflows/runsstarts a run inline and holds the connection until it finishes, returning the complete result. Its optionalworking_directoryfield selects the script run's project root; it is ignored for YAML workflows.caller_id(also on:submit,resumeand step replay) names the terminal starting the run; every agent step is then bounded by that terminal's recorded delegation ceiling. It is persisted on the run's journal row, so a resume re-binds to the original caller. A script-tierPOST /terminals/run-stepwhose run has no live record (server restarted) is refused with 403.POST /workflows/runs:submitstarts a run asynchronously: it returns202with{run_id, state, links}as soon as the run is durably journaled, then drives the run in the background. Thelinksmap always carriesself/status/result/cancel;eventsappears only on a build that serves the events route, so treat it as optional.GET /workflows/runslists journaled runs newest-first (?state=,?limit=).GET /workflows/runs/{run_id}inspects a run: run metadata, current state, and each step's durable projection — including the step's fulloutput_jsonanderrortext.⚠️ This is the most payload-bearing read on the surface, and the output it returns is not gated byworkflow_journal_capture_output; see the retention note in Configuration. It is a superset of the older status-snapshot shape, so callers reading onlystate/current_step_id/steps[].{id,state,attempts}are unaffected.GET /workflows/runs/{run_id}/resultreturns the complete retained result. It is assembled from the journal, so it answers for a detached, in-flight, or post-restart run — not only a finished one. No run-leveloutputfield is returned (run-level output is not journaled); per-step outputs are onsteps[].output.POST /workflows/runs/{run_id}/cancelcooperatively cancels a run;POST /workflows/runs/{run_id}/resumere-drives a crashed/failed one.POST /workflows/runs/{run_id}/steps/{step_id}:replayre-executes one recorded step live, resolving its prompt from that run's journal (spec snapshot, resolved inputs, predecessor outputs). Optional body{prompt_override}replaces the prompt template (its{{...}}references still resolve against the run). Write-scoped, because it runs an agent — but read-only with respect to the run: no journal row is written and no event is emitted, so a step can be re-probed repeatedly, concurrently, and on a run that is still going. That also means replays are invisible to a journal-as-audit-log reader (they are logged at INFO instead). YAML tier only (script tier -> 400). A step that FAILS is a200whose body carrieserror/error_kind; unknown run or unknown step -> 404, unusable spec snapshot -> 422, unresolvable or empty prompt -> 400. The200covers terminal-layer failures too, not only step timeouts: a provider that never initializes and a completed step whose output cannot be extracted are both reported as data, since neither says the request was bad. The 4xx/422 list is exactly the preflight checks — every one of them is decided before the step runs. Approval-relevant profile or memory drift returns409withdetail.kind: "plan_inputs_changed"before the step launches.GET /workflows/runs/{run_id}/eventsreturns the run's ordered event timeline with any declared gaps. One content-negotiated path, two arms: sendAccept: text/event-stream(or?stream=true) for a live SSE follow, otherwise a JSON page.?after_seq=is the replay cursor and must be>= 0; on the SSE arm it takes precedence overLast-Event-ID. A gap is data the server declares when an append was lost — never inferred by the client from seq numbering.GET /workflows/runs/{run_id}/compare?against={other_run_id}reports per-step differences between two runs. Outputs are compared at the reference level, never by diffing payloads. An unknown id on either side is a 404, not a partial compare.GET /workflows/runs/{run_id}/diagnosticsreturns a troubleshooting bundle: spec identifier + content hash, sanitized inputs, the event timeline with declared gaps, step outcomes, provider/agent/engine environment, and terminal/artifact references. Output excerpts appear only whenworkflow_journal_capture_outputis on;capture_enabledin the body declares which posture produced the bundle.DELETE /workflows/runs/{run_id}removes a run and all its retained data (run row, steps, events, seq high-water) in one cascade. Requires a write or admin scope. A running run returns 409 — cancel it first; an already-absent run returns 204.GET /terminals/{id}/output/range?start=&length=reads a byte-exact window of a terminal's append-only log, for correlating a step with the terminal output it produced.lengthis capped server-side.
All five reads above (inspect, events, compare, diagnostics, and the run list)
require a cao:read, cao:write, or cao:admin scope when authentication is
enabled. With no IdP and no CAO_AUTH_LOCAL_TOKEN configured — the default —
that check is inert.
See Workflows.
Read-only views of a CAO cluster's workers, reached through the fleet's worker broker
(CAO_ELASTIC_BROKER_URL / CAO_ELASTIC_BROKER_TOKEN in cao-server's environment). No
broker configured, unreachable, or erroring is a 503 carrying the broker's message.
Releasing workers and shutting the fleet down stay CLI actions (cao worker release,
cao fleet shutdown).
GET /fleet/statusmirrorscao fleet status:{broker, live, cleanup_pending, states}.GET /fleet/workersmirrorscao worker list(?all=truefor--all):{broker, workers: [{worker_id, state, reason, agent_profile, provider, age_seconds, workload_present, cleanup_pending, lease_tracked}]}. Only those fields are returned.GET /fleet/workers/{worker_id}mirrorscao worker status:{worker_id, lease, terminal, terminal_error, last_output}.terminalcarries{id, name, session_name, agent_profile, provider, status};last_outputis capped at 4000 characters. A malformed id is400; no lease and no answering worker is404.
/settings/memoryreports memory enablement (includinglearning_enabled)./memory*lists, reads, exports, and deletes memories./memory/relationships*lists, creates, patches, promotes, rejects, and soft-deletes typed relationships between memories.GETis read-scoped and capped bylimit(default 50, max 100); the mutating routes are write-scoped.DELETEis a soft-delete — the row is retained withstatus=deleted./graph/{provider}*projects and exports graph views./outcomesrecords (POST, write-scope) and lists (GET) workflow outcomes for the self-learning loop. Both return 404 whilememory.learning_enabledis false, and 503 whensettings.jsonexists but cannot be read — learning fails closed, so an unreadable file resolves to "disabled" internally, and reporting that as 404 would tell the caller a configuration story about a filesystem fault. These are the routes thereport_outcome/list_outcomesMCP tools call.POST /outcomesalso takes an optionallaunch_terminal_id(the MCP tool'sterminal_id), which links the outcome to that terminal's launch record. The link is checked against the stored record before anything is written: only a self-report (source_terminal_idequalslaunch_terminal_id) or the launch's direct creator (the record'scaller_id) may link. The route answers 403 and writes nothing when records exist for that id and none is bound to the reporter, or whenlaunch_terminal_idcomes without asource_terminal_id. Whenlaunch_terminal_idis sent, the response carrieslaunch_linked:trueif the outcome was joined to a launch record, andfalseif it was written without a link (no record for the id, a self-report on a launch that has already ended, or a link that could not be read or written)./internal/memory/*serves the memory tools on a node that does not own the database (CAO_MEMORY_API_URL). Itskeyis validated leniently and then normalised, matching what the tools have always done in-process — the strict operator rule would reject keys that work without the gateway.
See Memory, Self-Learning, and Knowledge Graph Viewing.
/flows*creates, lists, reads, deletes, enables, disables, and runs scheduled flows.
See Flows.
Connect to:
/terminals/{terminal_id}/ws
The path must identify an existing terminal. An accepted connection grants full read/write access to that terminal's PTY, including command input.
Authentication is enabled when CAO_AUTH_JWKS_URI or AUTH0_DOMAIN is set
(IdP mode), or when CAO_AUTH_LOCAL_TOKEN is set on its own (local-token
mode). In IdP mode the handshake requires a valid bearer token granting
cao:write or cao:admin, matching POST /terminals/{terminal_id}/input.
These are existing CAO scopes; cao:read alone does not permit interactive
PTY access. In local-token mode the bearer must equal CAO_AUTH_LOCAL_TOKEN
exactly; a match carries the full scope set.
Send the token in the Authorization bearer header, or in the token query
parameter for clients that cannot set that header. An extracted header token
takes precedence over the query parameter. Passing the Origin check does not
supply a bearer token; the client must provide it. Keep token-bearing URLs out
of logs and use TLS for remote connections.
With none of the authentication-enabling variables set, no token is required and any caller that reaches the port has full PTY access. The client-IP and Origin restrictions still apply in every mode. See the authentication configuration.
By default, only loopback clients identified as 127.0.0.1, ::1, or
localhost are allowed. CAO_WS_ALLOWED_CLIENTS adds comma-separated client
IP addresses or hostnames to that allowlist. A literal * disables the
client-IP restriction. A handshake whose peer address cannot be determined is refused unless * is
set: an unattributable peer fails closed, it does not skip the check. The
pinned uvicorn never leaves the peer unset (its proxy-headers middleware
rewrites it to a host/port pair even for empty or malformed forwarded values),
so this guards against other ASGI servers or middleware, not a default deployment.
Adding clients or using * widens who can reach the PTY handshake; it does
not bypass authentication when enabled. With authentication disabled,
clients that also pass the Origin check receive full PTY read/write access.
Treat either allowlist change as a security-boundary change and do not expose
the endpoint to untrusted networks. See the
network configuration for
related server settings.
The server sends binary WebSocket frames containing raw PTY bytes.
Clients send JSON in text frames:
{"type":"input","data":"ls -la\n"}The input message writes the UTF-8 string in data to the PTY.
{"type":"resize","rows":24,"cols":80}The resize message changes the PTY dimensions. Missing values default to 24
rows and 80 columns.
Client-IP, Origin, and authentication refusals happen before the WebSocket
upgrade. They reject the HTTP handshake with 403, rather than delivering a
WebSocket close frame. The handler uses these server-side refusal codes:
4003: the client is outside the allowed client-IP set.4403: the browser Origin is not allowed.4401: authentication is enabled and the token is missing, invalid, or grants neithercao:writenorcao:admin.
After the connection is accepted:
4003: terminal/backend target metadata is invalid.4004: the terminal does not exist, or the backend cannot attach to it.- A normal viewer disconnect detaches that viewer and preserves the session.
Malformed JSON, missing input data, unsupported message types, and other forwarding errors do not currently have a documented stable application close code.