Skip to content

Exec and the stream protocol

POST/sandboxes/{id}/exec

Runs a command in the guest and streams the result as length-prefixed frames ([type:1][reserved:3][size:4 BE][payload]) where type is stdout, stderr, or exit; the exit payload is a JSON ExecResult. Set ?stdin=1 for a hijacked, full-duplex interactive exec.

Run a command inside the sandbox and stream its output.

Field Type Notes
cmd string[] Required, non-empty. cmd[0] is PATH-resolved inside the guest.
env object Added to (not replacing) the agent's environment.
cwd string Working directory. Empty = inherit from the agent.
timeout_s int Command deadline. On expiry the agent SIGKILLs the process group and sets timed_out. 0 = no agent-side deadline.

Validation errors (bad JSON, empty cmd, unknown sandbox) come back before streaming as a normal 4xx JSON error. Once validation passes, the daemon sends 200 with Content-Type: application/octet-stream and streams a frame protocol:

  • Each frame is an 8-byte header (1 type byte, 3 reserved bytes, a big-endian uint32 payload length) followed by the payload.
  • Response frame types: 1 = stdout, 2 = stderr, 3 = exit.
  • stdout/stderr frames arrive as the command runs; the final exit frame's payload is the ExecResult JSON.

Interactive exec

POST /sandboxes/{id}/exec?stdin=1 hijacks the connection into a full-duplex framed stream after the 200: the client sends inbound frames 4 = stdin and 5 = stdin-close (EOF), and reads the same stdout/stderr/exit frames back. State (cd, env) persists for the life of the long-lived process. This is what crucible shell <id> and sandbox exec -i use; it is line-buffered with no PTY.

Interactive exec over WebSocket

GET /sandboxes/{id}/exec with an upgrade handshake serves the same session for clients that cannot hijack a raw connection (fetch-based runtimes, anything behind an L7 proxy). The client's first message is the JSON ExecRequest; after that, the concatenated binary message payloads in each direction form exactly the same frame stream as the hijacked path, so one frame codec serves both transports.

Pre-upgrade failures (unknown sandbox) are plain HTTP errors on the handshake; post-upgrade validation failures close the socket with status 1008 and a reason. A plain GET without the upgrade handshake answers 426.

ExecResult

The exit-frame payload:

{
  "exit_code": 0,
  "duration_ms": 247,
  "signal": "",
  "timed_out": false,
  "oom_killed": false,
  "error": "",
  "usage": {
    "cpu_user_ms": 180,
    "cpu_sys_ms": 40,
    "peak_memory_bytes": 35389440,
    "page_faults_major": 2,
    "context_switches_involuntary": 14,
    "io_read_bytes": 0,
    "io_write_bytes": 0
  }
}
  • exit_code is -1 when the process was killed by a signal or never started.
  • signal is the signal name (e.g. "SIGKILL") when the process was signalled; empty on a clean exit.
  • oom_killed is a best-effort heuristic (SIGKILL not from our timeout plus peak RSS at 95%+ of guest memory), not a precise cgroup reading.
  • usage is null when the agent collected no stats, so callers can tell "no data" from "zeroes". I/O counters are per-process and approximate for sub-100 ms commands.

The frame protocol is specified language-neutrally in the wire protocol, with conformance fixtures under sdks/fixtures.

path parameters
object
idstringrequired

Sandbox ID, e.g. sbx_ab12cd34.

query parameters
object
stdinstring

Set to "1" for an interactive, full-duplex exec (hijacked stream).

Request bodyapplication/json
ExecReq
cmdarray | null
items
string
cwdstring
envobject
additional properties
string
timeout_sinteger
Responses
200 OKOK
stringbase64
400 Bad requestBad Request
ErrorResponse
errorstring
404 Not foundNot Found
ErrorResponse
errorstring
Authentication
bearerAuthHTTP bearer. Daemon API key. Omit on a keyless loopback daemon.
Request
POST/sandboxes/{id}/exec
cURL
curl '/sandboxes/{id}/exec?stdin=' \
  -X POST \
  -H 'Content-Type: application/json' \
  -d '{}'
Response
200 OK
No response body.
Was this page helpful?