Skip to content

Latest commit

 

History

History
585 lines (512 loc) · 34.5 KB

api.md

File metadata and controls

585 lines (512 loc) · 34.5 KB

API Overview

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.

Representative HTTP usage

curl http://localhost:9889/health
curl http://localhost:9889/sessions
curl http://localhost:9889/agents/providers

HTTP errors use standard status codes and generally return a JSON detail field. Authentication and network behavior depend on server configuration; see Configuration and Security.

HTTP route families

Health and auth discovery

  • GET /health reports service health. Its features list names capabilities a peer node can rely on; delegation_ceiling means this node bounds created terminals by the caller's delegation ceiling and honours caller_ceiling (see Tool Restrictions).
  • GET /.well-known/oauth-protected-resource publishes OAuth protected resource metadata when applicable.

Events and AG-UI

  • /events and /events/history expose server events.
  • /agui/v1/stream and /agui/v1/emit_ui provide the AG-UI stream and generative UI input.

See AG-UI for enablement, event shapes, and privacy boundaries.

Profiles, providers, and settings

  • POST /ephemeral-agents creates 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 optional idempotency_key, claim_id and model; per-call models refuse. It returns the claim, provider and stored tools, and provider_auto (whether the spec declared provider: "auto"), or a matching launched terminal with replayed: true.
  • POST /sessions/{session}/terminals launches it with agent_profile, caller_id, provider and optional claim_id query parameters. Claims are 32 lowercase hexadecimal characters. POST /sessions has no caller or claim parameter and refuses a created, pending ephemeral name with not_owner. Collected names get ephemeral_expired (409); never-created names get unknown_ephemeral (404), including on POST /sessions.
  • POST /terminals/run-step accepts claim_id for a newly created terminal; combining it with reuse_terminal_id gives 422. Policy refusals use {kind: "ephemeral_policy", rule, message}. assign and handoff launch creator-owned ephemeral names from the creator terminal.
  • GET /agents/profiles and GET /agents/profiles/{name} list and inspect installed profiles.
  • GET /agents/profiles/search ranks installed, loadable profiles by capability using the same service as cao profile find.
  • GET /agents/profiles/templates lists public template metadata (name and description only); internal template filesystem paths are never returned.
  • GET /agents/profiles/templates/{category}/{name}/schema returns a template's JSON-Schema.
  • POST /agents/profiles/templates/validate validates a config object against a template's JSON-Schema without writing a profile.
  • POST /agents/profiles/templates/preview validates and renders a template to Markdown without writing a profile.
  • POST /agents/profiles/validate validates a finished profile's frontmatter against the profile JSON-Schema plus CAO conventions, without writing anything. This is the HTTP equivalent of cao profile validate, and is distinct from templates/validate, which checks a template config against that template's own schema. Findings are severity-tagged (error or warning); only errors clear the valid flag, so warnings are advisory.
  • GET /agents/profiles/schema returns 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/install installs a profile.
  • POST /agents/profiles creates a profile in the local store from a supplied document. Named distinctly from install, which takes a bare profile name or an https:// URL rather than the document itself. The request carries name and content; the two identities of a profile, its storage key and its frontmatter name, must agree, so a mismatch is a 400 rather than a silent rename. A conflicting name returns 409. Requires cao:write or cao: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. Requires cao:write or cao:admin.
  • DELETE /agents/profiles/{name} removes a profile from the local store. Requires cao:write or cao: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 exactly cao: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, so 2026-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 content cap 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 mcpServers entry must define either command, for a server CAO launches, or url, for a remote one whose type names its transport. The schema previously required command unconditionally, 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. url is the spelling resolve_mcp_server_config documents; 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 detail shape, {"message", "errors"}, so a client never has to switch on the type of detail. errors is 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-string detail, since the status code already discriminates and there are no findings to attach.
  • GET /agents/profiles/{name}/source returns a profile's document exactly as stored. Use this, not GET /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. Requires cao:read, cao:write, or cao: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.json file.
  • /agents/providers reports provider availability.
  • /settings/* exposes supported agent-directory, skill-directory, and memory settings.

See Agent Profiles and Configuration.

Skills

  • /skills/{name} retrieves an installed skill.

See Skills for discovery, installation, and catalog behavior.

Sessions and terminals

  • /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/list rely on that. Sort client-side on last_active if 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}/terminals requires caller_id (403 without it) and checks the requested allowed_tools (client string or profile resolution) against that caller's recorded ceiling, answering 403 when 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) uses POST /sessions/{session_name}/terminals:operator instead: same query parameters minus caller_id/defer_init, an optional {"description": ...} body, and the new terminal records the profile's declared ceiling exactly as POST /sessions does. It is a distinct sub-path so no existing request can land on it by accident, and it answers 400 when the request also names a caller (caller_id, caller_ceiling, delegating_terminal_id, or a body env_vars carrying CAO_CALLBACK_TERMINAL_ID). POST /sessions?memory_manager=true reports the sidecar's outcome on the response as memory_manager ({"state": "scheduled" | "refused", "detail"}); a primary whose ceiling does not cover the sidecar's @cao-mcp-server, fs_read, fs_list gets refused and no sidecar. A create's response may carry launch_notes, a list of strings for the operator; today only a Kiro v3 launch that the operator's kiro.v3_allow_unverified_versions opt-in admitted on a kiro-cli version still pending verification (provisional, #1167). It is never persisted and is null otherwise. POST /sessions and POST /terminals/run-step accept caller_ceiling in the JSON body for a caller on another node (remote assign/handoff); it is rejected together with a local caller_id, it is required when the body's env_vars carry CAO_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_id and caller_ceiling remain 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=full returns the StatusMonitor rolling buffer (most recent state_buffer_max bytes 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, default false, issue #100 Phase 1): provisions an isolated git worktree on its own branch instead of sharing working_directory as 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 MCP handoff/assign tool descriptions for the full behavior.
  • POST /sessions accepts optional group/metadata at creation, opting a session's initial terminal into peer discovery (a mid-session worker uses PATCH /terminals/{terminal_id}/group/metadata instead — see below). group is an ordered, general-to-specific array (e.g. ["tenant_1", "project_5"]); metadata is a free-form JSON object the running agent updates via the update_metadata MCP tool. Both PATCH endpoints are whole-value replace, not merge, last-write-wins under concurrent calls, and reject an omitted field with 422 (an explicit null/[]/{} clears the value; omitting it does not).
  • GET /terminals/{terminal_id}/siblings lists other terminals sharing a leading prefix of terminal_id's own group, optionally narrowed by depth; a caller can never see a wider scope than its own group, and a terminal with no group set finds no siblings. Session-scoped by default — results are also filtered to the caller's own tmux session unless the explicit cross_session=true opt-in is passed. Sibling metadata is agent-authored, untrusted content — same trust domain as an inbound send_message body. The list_siblings/update_metadata MCP tools also require the discovery entry in allowedTools — 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 descriptions

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.

Delegation body fields

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 (default null) is accepted by all three bodies. If supplied, an empty string returns 422; otherwise it must be a nonempty string or omitted entirely. On CreateTerminalBody/CreateSessionBody it is the only source the delegation decision reads — the decision runs only when it is present. On RunStepRequest it substitutes for prompt as the decision's input when supplied, and falls back to prompt when it is not.
  • delegation_kind: "handoff" | null (default null) is accepted by CreateTerminalBody and CreateSessionBody only. Only the literal "handoff" is accepted; any other value returns 422. Setting it requires delegation_message to also be set and initial_message to be absent, or the request returns 422 ("delegation_kind requires delegation_message and no initial_message"). Setting delegation_message without delegation_kind instead requires initial_message to be set with initial_message_orchestration_type equal to "assign", or the request returns 422 ("delegation_message requires delegation_kind or an ASSIGN initial_message").
  • orchestration_type: "handoff" | null (default null) is RunStepRequest's equivalent of delegation_kind — this body has no delegation_kind field. orchestration_type and delegation_message must be supplied together or both omitted, or the request returns 422 ("orchestration_type and delegation_message must travel together"). That check runs first, so supplying only one of the two fields alongside reuse_terminal_id returns the "must travel together" 422, not the one below. Both fields combined with reuse_terminal_id return 422 ("delegation fields cannot be combined with reuse_terminal_id").

Durable handoff results

  • POST /terminals/run-step runs one agent step and accepts an optional job_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.kind distinguishes worker failures (error -> 502), in-band provider refusals (provider_error -> 502), and timeouts (timeout -> 504); callers branch on kind, not the numeric status alone. A reused Kiro terminal (reuse_terminal_id) whose identity check is still running answers identity_pending -> 409 with retryable: true: nothing was typed, so retry the same call once it is ready.
  • GET /handoff-results/{job_id} retrieves the persisted record for that job_id. state is one of running (step still in progress), completed (result in last_message), or error (failure in error_message). 404 means the job_id is unknown — never sent, or already purged by the retention sweep (RETENTION_DAYS, same window as terminals and inbox messages).
  • Use it when a handoff returned pending=true with a job_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 the get_handoff_result MCP tool rather than this route directly.
  • Scope-gated like other content-serving GETs (read, write, or admin): last_message carries raw worker output. Enforced only when an IdP is configured; a no-op on a default install with auth disabled. Treat job_id as a capability — it is the sole retrieval credential for that output.

Inbox

  • /terminals/{terminal_id}/inbox/messages sends and reads terminal inbox messages.

Agents normally use the in-session supervisor protocols rather than calling these routes directly.

Workflows

  • /workflows* creates, updates, deletes, validates and inspects workflow specifications.
  • POST /workflows creates a Python spec (201); requires cao:write or cao:admin. Invalid specs or refused paths return 400, existing names or tier collisions 409, and storage failures 500.
  • PUT /workflows/{name} replaces a Python spec (200) using a required expected_hash; requires cao:write or cao:admin. Unknown names return 404, stale hashes or tier collisions 409, invalid specs or refused paths 400, and storage failures 500.
  • DELETE /workflows/{name} deletes a spec and its index row (200); requires cao:admin. An unknown or already-deleted name returns 404; invalid paths return 400.
  • POST /workflows/validate accepts exactly one of path or source (200); requires cao:read, cao:write or cao:admin. The source form lints a Python draft without writing a file; lint or size failures are reported in the 200 result. Supplying both forms or neither, or source that cannot be encoded as UTF-8, returns 400.

The spec-route scope checks apply when authentication is enabled.

  • POST /workflows/runs starts a run inline and holds the connection until it finishes, returning the complete result. Its optional working_directory field selects the script run's project root; it is ignored for YAML workflows. caller_id (also on :submit, resume and 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-tier POST /terminals/run-step whose run has no live record (server restarted) is refused with 403.
  • POST /workflows/runs:submit starts a run asynchronously: it returns 202 with {run_id, state, links} as soon as the run is durably journaled, then drives the run in the background. The links map always carries self/status/result/cancel; events appears only on a build that serves the events route, so treat it as optional.
  • GET /workflows/runs lists 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 full output_json and error text. ⚠️ This is the most payload-bearing read on the surface, and the output it returns is not gated by workflow_journal_capture_output; see the retention note in Configuration. It is a superset of the older status-snapshot shape, so callers reading only state/current_step_id/steps[].{id,state,attempts} are unaffected.
  • GET /workflows/runs/{run_id}/result returns 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-level output field is returned (run-level output is not journaled); per-step outputs are on steps[].output.
  • POST /workflows/runs/{run_id}/cancel cooperatively cancels a run; POST /workflows/runs/{run_id}/resume re-drives a crashed/failed one.
  • POST /workflows/runs/{run_id}/steps/{step_id}:replay re-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 a 200 whose body carries error/error_kind; unknown run or unknown step -> 404, unusable spec snapshot -> 422, unresolvable or empty prompt -> 400. The 200 covers 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 returns 409 with detail.kind: "plan_inputs_changed" before the step launches.
  • GET /workflows/runs/{run_id}/events returns the run's ordered event timeline with any declared gaps. One content-negotiated path, two arms: send Accept: 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 over Last-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}/diagnostics returns 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 when workflow_journal_capture_output is on; capture_enabled in 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. length is 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.

Fleet

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/status mirrors cao fleet status: {broker, live, cleanup_pending, states}.
  • GET /fleet/workers mirrors cao worker list (?all=true for --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} mirrors cao worker status: {worker_id, lease, terminal, terminal_error, last_output}. terminal carries {id, name, session_name, agent_profile, provider, status}; last_output is capped at 4000 characters. A malformed id is 400; no lease and no answering worker is 404.

Memory and graph

  • /settings/memory reports memory enablement (including learning_enabled).
  • /memory* lists, reads, exports, and deletes memories.
  • /memory/relationships* lists, creates, patches, promotes, rejects, and soft-deletes typed relationships between memories. GET is read-scoped and capped by limit (default 50, max 100); the mutating routes are write-scoped. DELETE is a soft-delete — the row is retained with status=deleted.
  • /graph/{provider}* projects and exports graph views.
  • /outcomes records (POST, write-scope) and lists (GET) workflow outcomes for the self-learning loop. Both return 404 while memory.learning_enabled is false, and 503 when settings.json exists 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 the report_outcome / list_outcomes MCP tools call. POST /outcomes also takes an optional launch_terminal_id (the MCP tool's terminal_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_id equals launch_terminal_id) or the launch's direct creator (the record's caller_id) may link. The route answers 403 and writes nothing when records exist for that id and none is bound to the reporter, or when launch_terminal_id comes without a source_terminal_id. When launch_terminal_id is sent, the response carries launch_linked: true if the outcome was joined to a launch record, and false if 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). Its key is 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

  • /flows* creates, lists, reads, deletes, enables, disables, and runs scheduled flows.

See Flows.

PTY WebSocket

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

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.

Client access boundary

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.

Frames and messages

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.

Close outcomes

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 neither cao:write nor cao: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.