The coppice CLI

Cube ships a cubemastercli binary that covers the operator’s daily surface area: create a sandbox, list what’s running, kill the one that ate all the RAM, build a template from a directory, warm a pool. We didn’t have that. We had curl | jq and coppice-pool-ctl. This page is about closing that gap with a single Rust binary that ships from the same Cargo workspace as the gateway and speaks the gateway’s JSON wire format directly.

The shape

The binary is called coppice. It lives at e2b-compat/src/bin/coppice.rs in the same crate as the gateway, so it compiles against whatever the gateway’s Cargo.lock says — no protocol drift between client and server. Install with mise run coppice:install, which shells out to cargo install —path e2b-compat —bin coppice —locked and drops the binary in ~/.cargo/bin.

The subcommand surface mirrors Cube’s CLI deliberately, so an operator who’s been doing Cube for a year finds what they expect:

commandgateway routenote
coppice sandbox create [—template] [—ttl] [—cpu] [—mem] [—json]POST /sandboxesPrints the id on stdout (or full JSON with —json). —ttl follows up with POST /sandboxes/:id/timeout.
coppice sandbox list [—json]GET /sandboxesAligned columns by default: id, template, state, started, end.
coppice sandbox kill <id>DELETE /sandboxes/:idExit 2 on 404, so shell pipelines can branch on it.
coppice sandbox exec <id> — <argv…>POST /sandboxes/:id/execRelays stdout verbatim; the separator is mandatory when argv has flags.
coppice sandbox logs <id>GET /sandboxes/:id/logsSnapshot only; —follow is advertised-but-unimplemented and exits 3.
coppice tpl list / show / create-from-dirThe gateway has no /templates endpoint yet. The CLI surface is shaped and returns exit 3 with a “not implemented” stderr note; scripts don’t have to change when the endpoint lands.
coppice pool status / warm / drainSame story — coppice-pool-ctl on the host handles this today; CLI stubs exit 3 until the endpoints move into the gateway.

Gateway URL resolution

Four sources, in priority order: —url <u>, $COPPICE_URL, $E2B_API_URL, http://localhost:3000. The $E2B_API_URL rung matters — it’s what the official Python SDK honours, and picking it up means an operator can point their shell at a remote gateway once and have both coppice and any SDK scripts agree without re-exporting. The flag strips trailing slashes so —url http://localhost:3000/ and —url http://localhost:3000 are the same thing.

There’s a reserved —token flag that’s a no-op today. When B3 (air-gapped sandboxes) ships auth, the CLI will read $COPPICE_TOKEN and emit a bearer header; we wanted the flag shape in place now so scripts forward-compat cleanly.

A worked session

From a laptop, tunnelled into honor via ssh -L 3000:localhost:3000 -L 49999:localhost:49999 -L 49983:localhost:49983 honor, talking to the live gateway rc.d service (2026-04-22, verbatim):

$ coppice sandbox create --template python --json | jq -r .sandboxID
609a21703c314940b66532095ac74ad9

$ coppice sandbox list
SANDBOX_ID                        TEMPLATE  STATE    STARTED                         END
609a21703c314940b66532095ac74ad9  python    running  2026-04-22T23:57:06.487003909Z  2026-04-23T00:12:06.487004230Z

$ coppice sandbox exec 609a21703c314940b66532095ac74ad9 -- python3 -c 'print("hello from CLI")'
hello from CLI

$ coppice sandbox kill 609a21703c314940b66532095ac74ad9
$ echo $?
0

Note the interpreter is python3, not python: the FreeBSD python template pkg-installs python3.11 as python3 with no python alias. The E2B Python SDK’s run_code path is unaffected (it reaches the in-jail ipykernel via /execute); this only bites on ad-hoc coppice sandbox exec invocations.

That’s the critical path. Creating with resource limits is one flag each: —cpu 50 —mem 256 forwards cpuCount=50 (rctl pcpu in percent) and memoryMB=256 to the same handler path the SDK uses, so the limits bind before jexec’s first syscall — same story as the feature-audit row.

Against cubemastercli

Cube’s CLI is bigger than ours: cluster-admin subcommands, auth flows, log streaming with cursor state. We don’t yet have clusters, auth, or streaming logs. The CLI surface reflects that honestly — the endpoints we have are fully wired, the ones we don’t exit 3 with a named reason. An operator running coppice pool warm python —count 20 is told explicitly that the route isn’t in the gateway yet, not given a silent failure or a fake success.

The rows we cover outright, though, cover the day-to-day: sandbox create / list / kill / exec is 90% of what hits a CLI in anger. examples/10-cli-roundtrip.sh in this repo runs that full cycle as a single shell script and is what we point at in CI and in demo scripts.

Gaps the CLI surfaced

Writing the CLI flushed out two small gateway rough edges that weren’t visible from the SDK’s happy path:

  1. DELETE /sandboxes/:id on a nonexistent id returns HTTP 500, not 404. The backend error wrapper treats “jail not found” as a generic backend error rather than translating it to the shape the SDK already handles for other 404 paths. The CLI handles either (exit 1 on 500, exit 2 on 404) so it’s not a blocker; just worth fixing the next time someone’s in routes.rs.
  2. No /templates endpoint family on the gateway yet. coppice tpl list / show / create-from-dir and coppice pool warm / drain / status all map onto surface area that currently only exists as coppice-pool-ctl host-side tooling. The CLI emits an explicit “not implemented” stderr note and exits 3 for each, rather than silently succeeding or returning a generic HTTP error — so scripts fail loudly and for the right reason.

Receipts

One more row closed in the feature audit, one more binary that new operators can run on day one instead of learning the gateway’s JSON by hand.