ws://<host>:8121/ (TLS variant: wss://...). The management server's
WebSocket listener handles command execution plus the formal role-based
session protocol that powers sandboxctl session attach
(management/src/session/). The older legacy agent-scoped protocol is
compatibility-only for trusted dashboards.
The dashboard discovers this endpoint from
GET /api/v2/admin/bootstrap/readiness. Direct deployments advertise
ws://{host}:8121/; reverse proxies should set
AGENTIC_MANAGEMENT_WS_PUBLIC_URL to their public wss: URL. A dashboard
served over HTTPS rejects a plaintext advertised endpoint.
This doc is the operator/integrator reference. The Rust source of truth
is management/src/ws/connection.rs;
when adding a new message type here, update both.
Normal terminal clients should use the formal session protocol (
JoinSession/LeaveSession/SessionInput/SessionResize+SessionFrame). It provides per-session fanout, replay, and observer/controller policy. Legacy agent-scoped subscriptions are disabled by default and should only be re-enabled for trusted compatibility dashboards during migration.
const ws = new WebSocket("ws://localhost:8121/");
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "server_hello") {
if (!msg.supported_client_messages.includes("join_session")) {
ws.close(1002, "join_session unsupported");
return;
}
// Send nothing before this validated capability advertisement.
ws.send(JSON.stringify({ type: "join_session", session_id: "sess-01", role: "observer" }));
return;
}
if (msg.type === "session_frame" && msg.kind === "output") console.log(atob(msg.data));
};One-shot command output is scoped to the WebSocket connection that started
the command. A client that sends send_command receives output frames for
the returned command_id without subscribing to every terminal byte for the
agent.
The old auto-subscribe behavior has been removed for normal clients. These legacy PTY/session verbs no longer grant broad agent output fanout:
attach_sessionstart_shellcreate_sessionsend_command
For existing terminal sessions, call list_sessions to find the stable
session_id, then use join_session to receive session_frame events.
Normal clients have two output paths:
send_commandoutput is delivered only to the connection that started the command, keyed bycommand_id.- PTY/session output is delivered through the formal session registry as
session_frameevents keyed bysession_id.
The old agent-scoped subscriber path is compatibility-only. It forwards every command output frame for the subscribed agent and is disabled by default because that leaks terminal bytes across sessions.
The historical agent_id = "*" wildcard subscribed to all agents.
That fanout is deprecated because it exposes every agent's terminal
output to one connection. New deployments reject wildcard subscriptions
by default. Set AGENTIC_WS_ALLOW_WILDCARD_SUBSCRIBE=true only for a
trusted legacy dashboard. Concrete agent_id subscriptions are also
disabled by default; set AGENTIC_WS_ALLOW_AGENT_SUBSCRIBE=true only for a
trusted legacy dashboard while migrating to the formal session protocol.
| Type | Required fields | Notes |
|---|---|---|
subscribe |
agent_id |
Compatibility-only. Rejected by default for both concrete ids and agent_id="*". Concrete ids require AGENTIC_WS_ALLOW_AGENT_SUBSCRIBE=true; wildcard also accepts the narrower AGENTIC_WS_ALLOW_WILDCARD_SUBSCRIBE=true. |
unsubscribe |
agent_id |
Remove from subscriber set. Idempotent. |
ping |
timestamp |
Round-trip keepalive. |
list_agents |
(none) | Returns the registered-agent list. |
list_sessions |
agent_id |
Authoritative source for session_name ↔ command_id mapping. |
attach_session |
agent_id, session_name, cols, rows |
Compatibility-only. Rejected by default for normal clients; use list_sessions + join_session. |
detach_session |
agent_id, session_name |
Server-side no-op (output keeps flowing); use unsubscribe to stop receiving. |
kill_session |
agent_id, session_name |
Optional signal (i32). Kill lookup is by session_name, not command_id. |
create_session |
agent_id, session_name, session_type, command, args, working_dir, cols, rows |
session_type: interactive | headless | background. Returns stable session_id; use join_session for output. |
start_shell |
agent_id, cols, rows |
Spawn a fresh interactive PTY. Output for the newly-started command is scoped to the creating connection; use join_session for replay/multi-client attach. |
send_command |
agent_id, command, args |
One-shot dispatch. Output for the returned command_id is scoped to the creating connection. |
send_input |
agent_id, command_id, data |
Raw PTY input. command_id is the value returned by attach_session / start_shell / session_list. |
pty_resize |
agent_id, command_id, cols, rows |
Trigger after the local terminal resizes. |
| Type | Required fields | Notes |
|---|---|---|
server_hello |
protocol_version, supported_client_messages[], features[] |
Sent as the first frame on every WS connection — capability banner. Clients should read this before issuing any other message and feature-gate based on the advertised arrays. Constants live at management/src/ws/connection.rs:140 (SUPPORTED_CLIENT_MESSAGES, SUPPORTED_FEATURES). |
subscribed |
agent_id |
Ack — client may now receive output. |
unsubscribed |
agent_id |
Ack. |
pong |
timestamp |
Echo of the client's ping.timestamp. |
error |
message |
Generic error. May arrive in response to any client message. |
agent_list |
agents[] |
Each entry: agent_id, hostname, ip, status, etc. |
session_list |
agent_id, sessions[] |
Each session entry: session_name, command_id, session_id (stable UUIDv7), running, command. |
session_attached |
agent_id, session_name, command_id |
Use command_id for subsequent send_input / pty_resize. |
session_detached |
agent_id, session_name |
Confirms client intent; output may still flow if subscribed. |
session_killed |
agent_id, session_name, exit_code |
Final notification. |
session_created |
agent_id, session_name, session_type, command_id |
The command_id here is actually the stable session_id (formal-protocol id), not the ephemeral PTY command_id. Use list_sessions to resolve to a wire command_id for input/resize. |
shell_started |
agent_id, command_id |
Note: session_name is not echoed — call list_sessions to resolve. |
command_started |
agent_id, command_id, command |
Sent in response to send_command. |
output |
agent_id, command_id, stream, data, ts |
stream: stdout | stderr | log. data is a UTF-8 string (PTY output). |
metrics_update |
agent_id, cpu_percent, memory_*, ... |
Periodic snapshot pushed by the agent. |
input_sent |
agent_id, command_id |
Confirmation that the input was forwarded to the agent. |
command_id≠session_name≠session_id.command_idis the ephemeral PTY handle (changes if the agent restarts the command);session_nameis the human-readable key used byattach_session/kill_session;session_idis the formal-protocol stable UUIDv7 that survives reconnects.list_sessionsis authoritative for all three.subscribeis privileged compatibility. Normal clients should not use it. It is disabled unless the operator explicitly enables the legacy agent fanout env vars.- Output is no longer broadcast per
agent_idfor normal clients.send_commandoutput is keyed to the creating connection andcommand_id; PTY output is keyed tosession_idthrough the formal protocol. - Wildcard subscriptions are disabled by default.
agent_id="*"was a legacy dashboard convenience and now requires the operator opt-inAGENTIC_WS_ALLOW_WILDCARD_SUBSCRIBE=true. attach_sessionis compatibility-only. Uselist_sessionsto resolvesession_nametosession_id, thenjoin_sessionfor actual observation or control.start_shellis idempotent for an existing session. Calling it twice for the same(agent_id, session_name)returns the samecommand_idwithout spawning a duplicate PTY (ce8e600).session_createdreturnscommand_idset to the stablesession_id, not the PTY command_id. Resolve to the realcommand_idvialist_sessionsbefore sendingsend_input/pty_resize.
For role-based PTY sessions with replay buffer support — used by
sandboxctl session attach. The formal registry grants a singleton controller
lease; additional controller requests are downgraded to observer until the
controller leaves or the stale controller channel is reaped. The pty-ws/v1
reference profile follows the same single-controller-plus-observers model.
Same WS endpoint; messages are dispatched by type:
| Type | Direction | Required fields |
|---|---|---|
join_session |
C→S | session_id, role (controller|observer), optional replay_from |
leave_session |
C→S | session_id |
session_input |
C→S | session_id, data (UTF-8 PTY input) |
session_resize |
C→S | session_id, cols, rows |
session_joined |
S→C | session_id, role, current_seq |
session_left |
S→C | session_id |
session_frame |
S→C | session_id, seq, ts, kind (Output/Resize/RoleAssigned/MembershipChanged/Keyframe/Closed/Error) plus per-kind fields |
session_frame payloads (selected by kind):
output:stream(stdout|stderr|log),data(base64-encoded raw PTY bytes)resize:cols,rowsrole_assigned:rolemembership_changed:controllers[],observers[](lists of client_ids)keyframe:data(base64-encoded full-screen snapshot used for replay-safe resync — seekeyframesfeature flag)closed:exit_code(optional i32)error:message
Fresh joins replay from the most recent keyframe when one is retained. If no
keyframe exists yet, the server replays from the oldest retained ring frame so
late observers do not start from a blank terminal. Explicit replay_from
requests still start at the requested sequence when available.
See management/src/session/registry.rs
and management/src/ws/connection.rs
for the canonical message definitions.
A separate WS stream connects the management server outbound to an
AIWG aiwg serve instance for the executor-contract integration:
ws://<aiwg-serve>/ws/executors/{executor_id}?token=<bearer>
This stream carries the mission.* event vocabulary and executor.resync
(sandbox → AIWG) plus inbound mission.hitl_responded (AIWG → sandbox).
It runs in parallel with the existing /ws/sandbox/{sandbox_id} push
connection — failures on one do not stall the other.
The 11 mission/executor event types, envelope shape, persistence model,
and graceful-shutdown lifecycle are documented in detail in
AIWG Executor Contract. The wire shape follows the
same { event, executor_id, mission_id, ts, data } envelope as the
formal session protocol above.
The legacy agent-scoped protocol is the right choice when you want the
simplest "give me a PTY on this agent and stream it" handshake.
AIWG's src/serve/pty-bridge.ts is the reference implementation
shipping today; this section walks through the canonical handshake so
a Go / Python / browser client can reproduce it without re-deriving
the message order from the message tables alone.
Connection state your client needs to maintain:
{
agent_id: "agent-01", // who you're talking to
command_id: null, // captured from shell_started; identifies the PTY for stdin/resize
session_name: null, // captured from session_list; needed for kill_session
}
Step-by-step:
-
Connect:
ws://<host>:8121/. -
On
open, start the shell:{ "type": "start_shell", "agent_id": "agent-01", "cols": 120, "rows": 30 }start_shellis idempotent for an existing session perce8e600: the second client to call it for the same(agent_id, session_name)gets the samecommand_id— no duplicate PTY is spawned. Output for the returned command is scoped to this WebSocket connection. Other clients should use the returned formalsession_idviajoin_session, not legacy agent subscription. -
Server replies with
shell_started { agent_id, command_id }. Capture thecommand_id— every subsequentsend_input/pty_resizeyou send carries it, and everyoutputevent you receive is filtered by it. -
Immediately after
shell_started, send:{ "type": "list_sessions", "agent_id": "agent-01" }You need this because
start_shelldoesn't echo the human-readablesession_name, butkill_sessionrequires it. -
Server replies with
session_list { agent_id, sessions[] }. Find the entry whosecommand_idmatches yours; store itssession_name. -
Stream loop: handle inbound
output { agent_id, command_id, stream, data, ts }— filtercommand_id === yours(the server broadcasts every command's output on this agent_id; the filter is your only routing).datais a UTF-8 string in this protocol; write it straight to your terminal. Outbound: sendsend_input { agent_id, command_id, data }for stdin andpty_resize { agent_id, command_id, cols, rows }on local terminal resize. -
On disconnect (network blip, mgmt-server restart, anything), reconnect with exponential backoff and re-run steps 2–5. Because
start_shellis idempotent, you'll get the samecommand_idback; the underlying tmux session is preserved as long as at least one subscriber remains. Post-#145 the server emits a Keyframe payload on first attach — if you handle the formal session protocol'skeyframekind you get a safe full-repaint start; if you don't, your terminal will see a normal output burst that includes the cursor/SGR sequences (still correct, just not labeled). -
To stop: send
kill_session { agent_id, session_name }. The server usessession_namehere, notcommand_id— that's why you captured it in step 5.
Future upgrade path — when your client needs role-gated control (distinct controller vs observer roles, hand-off, optional multi-writer membership when the server advertises it), migrate to the formal session protocol:
| Legacy | Formal |
|---|---|
subscribe + start_shell |
join_session { session_id, role: "controller"|"observer", replay_from? } |
send_input { command_id, data } |
session_input { session_id, data } |
pty_resize { command_id, cols, rows } |
session_resize { session_id, cols, rows } |
filter output by command_id |
listen for session_frame { session_id, kind, ... } |
kill_session { session_name } |
DELETE /api/v1/sessions/{session_id} (REST) |
The formal protocol unlocks replay_from, the lagged event signal,
the MembershipChanged snapshot, and post-#147 raw-bytes ring storage.
sandboxctl session attach is the canonical reference implementation
of the formal protocol — see cli/src/cmd/session.rs.
docs/cli-design.md—sandboxctl session attachflow on top of the formal protocoldocs/SESSION_RECONCILIATION.md— what survives a server restartdocs/API.md— HTTP/REST reference
All messages are JSON text frames. Discriminant is type (snake_case).
Payload of an output frame is a UTF-8 string in the legacy protocol,
and base64-encoded raw bytes in the formal protocol's
session_frame.kind=output (so binary-safe for non-UTF-8 PTY output).