Commands streaming

The E2B SDK exposes a sandbox.commands.* surface for running shell commands inside a sandbox with streaming stdout/stderr, a PID handle, and kill() / send_stdin() on live processes. Unlike the code-interpretation surface (sandbox.run_code(…)POST /execute with NDJSON lines), commands speak Connect-RPC — a flavour of protobuf JSON over HTTP with a small envelope header on server-streaming calls. This appendix lays out what Coppice puts on the wire and why.

Endpoints

The SDK’s e2b.sandbox_sync.commands.Commands points a ProcessClient at the envd URL with json=True, which produces seven routes under /process.Process/:

routekindpurpose
POST /process.Process/Startserver-streamspawn a command, stream its events
POST /process.Process/Connectserver-streamreattach to an already-started PID
POST /process.Process/Listunaryactive-process catalogue
POST /process.Process/SendSignalunarySIGTERM/SIGKILL a PID
POST /process.Process/SendInputunaryfeed stdin bytes to a PID
POST /process.Process/CloseStdinunaryclose a PID’s stdin
POST /process.Process/Updateunaryresize a PTY (ignored on commands)

Unary calls use content-type: application/json; the body is the protobuf-JSON encoding of the request message (see process.proto in the SDK). Responses are the same shape.

The envelope, and why NDJSON wasn’t enough

For Start and Connect the client sends one JSON body and the server emits a sequence of JSON messages. Connect-RPC’s server streaming wraps each message in a 5-byte framing header:

┌──── flags (1 byte) ────┬─ payload length (4 bytes, BE) ─┬── payload (length bytes) ──┐
│   0x00 = normal        │                                │ JSON-encoded ProcessEvent  │
│   0x01 = compressed    │                                │ or {} for end-of-stream    │
│   0x02 = end-of-stream │                                │                            │
└────────────────────────┴────────────────────────────────┴────────────────────────────┘

The terminal envelope sets flag 0x02 and carries {} on success or {"error":{"code":"...","message":"..."}} on failure. Coppice’s implementation lives in e2b-compat/src/commands.rs (encode_connect_envelope + encode_connect_end_stream).

NDJSON (one JSON object per line, no header) was tempting — that’s what /execute does — but the Python SDK’s Connect client buffers by envelope length, not newlines, and synthesises a {} terminator it needs to see before declaring the call done. So we serve real Connect-RPC here, and the matching /execute NDJSON stays as-is.

Events

Each Start/Connect payload is a StartResponse / ConnectResponse whose .event field is one of:

{ "event": { "start": { "pid": 12345 } } }
{ "event": { "data":  { "stdout": "aGVsbG8K" } } }
{ "event": { "data":  { "stderr": "b29wcwo=" } } }
{ "event": { "end":   { "exitCode": 0, "exited": true, "status": "", "error": "" } } }
{ "event": { "keepalive": {} } }

Bytes fields (stdout, stderr) are base64 — protobuf-JSON’s convention for raw bytes. The gateway line-reads each pipe and emits one data event per line, with the trailing newline preserved.

Lifecycle on the gateway

POST /process.Process/Start

  ├─► commands::spawn_command()         — jexec -l -U root e2b-<id> <cmd>
  │     ├─ state.commands.insert(pid, info)
  │     └─ metrics.inc_command_started()

  ├─► emit start envelope { pid }
  ├─► spawn stdout/stderr line-readers  ─► emit data envelopes
  │     └─ tee_log(sandbox, stream, line)  (sibling #73's LogBuffer)

  ├─► wait-task:
  │     child.wait()  → exit_code
  │     emit end envelope { exit_code }
  │     emit end-stream envelope (flag 0x02, {})
  │     finalize_command(state, pid, exit_code, killed=false)
  │       └─ state.commands.remove(pid)
  │       └─ metrics.inc_command_finished_{ok,error,killed}

  └─► response closes

A POST /process.Process/SendSignal { pid, signal } while the command is running bypasses the wait-task’s counter bump: it removes the entry from state.commands first and increments commands_finished_killed_total. The wait-task’s finalize_command call is idempotent — it sees the entry already gone and exits without bumping anything. No double-counting.

NDJSON alias: POST /commands

We also ship a raw NDJSON endpoint that’s not spoken by the SDK:

POST /commands  { "cmd": "seq 1 5" }

Streamed response:

{"type":"start","pid":87654}
{"type":"stdout","data":"1\n"}
{"type":"stdout","data":"2\n"}
{"type":"stdout","data":"3\n"}
{"type":"stdout","data":"4\n"}
{"type":"stdout","data":"5\n"}
{"type":"end","exit_code":0}

This exists so curl + shell rigs can exercise the whole lifecycle without reimplementing envelope framing. GET /commands, GET /commands/:pid, and POST /commands/:pid/kill round out the REST surface. The example at examples/11-commands-stream.py uses the real SDK, not this alias.

Timeouts

The SDK’s commands.run(cmd, timeout=60) passes that value to httpcore as the per-stream read timeout. The gateway additionally enforces it on the NDJSON alias via tokio::time::timeout(child.wait()) — pass timeout_ms=0 for no timeout, omit for 60 s, or an integer for the ms count. When the deadline fires the gateway issues SIGKILL and emits {"type":"end","exit_code":-1} with killed=true in the bookkeeping.

Metrics

Three Prometheus series land on /metrics:

coppice_commands_started_total
coppice_commands_finished_total{status="ok|error|killed"}
coppice_commands_active

commands_active is a gauge that goes up on Start and down on every terminal transition, so a scrape-time snapshot of live commands is cheap. The _finished_ partition matches exactly: every started command eventually bumps exactly one of the three statuses.

jexec presence

The gateway dev-host path (Linux, macOS, CI) skips the jexec wrapper when /usr/sbin/jexec is absent and runs the command directly. This keeps the HTTP surface testable on any box — the in-tree tests in tests/commands.rs and the inline unit tests in commands.rs hit the real router without needing a FreeBSD jail. On honor, the wrapper is always present, so the production path runs jexec -l -U root e2b-<id> <cmd> <args...>.