HTTP API
crucible is driven entirely over HTTP. This is the reference for the daemon's REST surface.
- Base URL: whatever you pass to
--listen(defaulthttp://127.0.0.1:7878). - Authentication. Off by default on loopback; required as soon as any API key exists. Pass
Authorization: Bearer <key>. See Authentication below and SECURITY.md. - Content type: requests and responses are JSON, except the exec stream (see below). Request bodies are size-capped; oversized bodies are rejected.
- Errors: any non-2xx JSON response has the shape
{"error": "message"}.
IDs are validated on every path; a malformed sandbox or snapshot ID returns 400 before any work happens.
Authentication
The daemon supports bearer-token API keys, on the same model as the Docker or Kubernetes CLIs.
- Loopback default: no auth. With no keys configured, the daemon (bound to
127.0.0.1) serves every request unauthenticated — the single-operator local case. - Enabling auth. Create a key with
crucible daemon token add --name <label>. The raw key is printed once (crucible_…); only its SHA-256 hash is stored. As soon as one key exists, every request must carryAuthorization: Bearer <key>or it is rejected401with aWWW-Authenticate: Bearerheader. Manage keys withcrucible daemon token list/revoke <id>(rotate = add a new key, then revoke the old); changes take effect without a daemon restart. /healthzis always exempt, so liveness probes work without a key.- Binding non-loopback requires auth + TLS. The daemon refuses to bind a non-loopback address unless at least one key exists and
--tls-cert/--tls-keyare set. Clients send the key over TLS with--token/CRUCIBLE_TOKEN.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/healthz |
Liveness check |
GET |
/metrics |
Prometheus metrics |
GET |
/profiles |
List configured rootfs profiles |
POST |
/sandboxes |
Create a sandbox |
GET |
/sandboxes |
List sandboxes |
GET |
/sandboxes/{id} |
Inspect a sandbox |
DELETE |
/sandboxes/{id} |
Destroy a sandbox |
POST |
/sandboxes/{id}/exec |
Run a command (streamed frames). Add ?stdin=1 for an interactive, full-duplex session — the connection is hijacked and the client sends FrameStdin/FrameStdinClose (what crucible shell / exec -i use). |
GET |
/sandboxes/{id}/exec |
WebSocket upgrade for the same interactive session — the transport for non-Go clients (browser-style HTTP APIs can't speak the hijacked stream). Gated as exec, like the POST. |
POST |
/sandboxes/{id}/files |
Push a tar stream into the guest, extracted beneath ?path=<dest> (the crucible cp push / MCP write_files). Streamed, not buffered; entries that escape the dest are rejected. Returns {files, bytes}. |
GET |
/sandboxes/{id}/files |
Read one guest file's bytes: ?path=<file>&max_bytes=<n> (the MCP read_file). Content only — nothing is written host-side, so there is no traversal surface. |
GET |
/sandboxes/{id}/logs |
Durable per-sandbox logs (entrypoint output + exec activity). Query: source=service|exec|all, since=<byte-cursor>, tail bounds. 501 when the daemon has no log store configured. |
PUT |
/sandboxes/{id}/service |
Configure the supervised entrypoint (spec). |
POST |
/sandboxes/{id}/service/start|stop|restart |
Start / graceful-stop (StopSignal + grace) / restart the entrypoint. |
GET |
/sandboxes/{id}/service |
Entrypoint supervisor status. |
GET |
/sandboxes/{id}/service/logs |
Cursored read of the supervisor's in-guest output ring (the raw source logs durably persists). |
POST |
/sandboxes/{id}/snapshot |
Snapshot a sandbox |
GET |
/snapshots |
List snapshots |
GET |
/snapshots/{id} |
Inspect a snapshot |
DELETE |
/snapshots/{id} |
Delete a snapshot |
POST |
/snapshots/{id}/fork |
Fork N sandboxes from a snapshot |
POST |
/images |
Pull + convert an OCI image into the store ({"ref": "nginx:alpine"}). |
POST |
/images/import |
Import a docker save archive stream into the store. |
GET |
/images · GET/DELETE /images/{ref} |
List / inspect / remove converted images. |
The image, service, and logs endpoints answer 501 when the daemon is started without the corresponding subsystem (--image-dir, a service-capable rootfs, --log-dir). Calling a known path with an unsupported method returns 405.
GET /healthz
Returns 200 with {"status": "ok"} once the daemon is serving. No side effects.
GET /metrics
Prometheus metrics in the standard text exposition format. Label-free today:
| Metric | Type | Meaning |
|---|---|---|
sandboxes_created_total |
counter | Sandboxes created (cold boots + forks) |
sandboxes_active |
gauge | Sandboxes currently live |
fork_duration_seconds |
histogram | End-to-end time to bring up one fork |
snapshot_restore_duration_seconds |
histogram | Time for the runner to restore a VM from a snapshot |
Like the rest of the API, /metrics is subject to auth when keys are configured (only /healthz is exempt) and is loopback-bound by default — scrape it from a colocated agent (with a token if auth is on), not across a trust boundary.
GET /profiles
Returns the rootfs profiles the daemon was configured with via --rootfs-dir, sorted (empty when none):
{ "profiles": ["base", "node-22", "python-3.12"] }These are the values accepted by the profile field of POST /sandboxes. See profiles.md.
POST /sandboxes
Create a sandbox. All fields are optional — an empty body {} boots a sandbox with daemon defaults.
{
"vcpus": 1,
"memory_mib": 512,
"boot_args": "",
"timeout_s": 0,
"network": {
"enabled": true,
"allowlist": ["pypi.org", "*.npmjs.org"]
}
}| Field | Type | Notes |
|---|---|---|
vcpus |
int | vCPU count. Defaulted and range-capped by the daemon. |
memory_mib |
int | Guest memory in MiB. Defaulted and range-capped. |
boot_args |
string | Extra kernel cmdline appended to the daemon's default. |
timeout_s |
int | Max sandbox lifetime in seconds. 0 = no timeout (lives until DELETE or shutdown). |
profile |
string | Pre-baked rootfs to boot from, e.g. "python-3.12", "node-22", "base". Empty uses the daemon's default --rootfs. Resolved against the daemon's --rootfs-dir; an unknown profile is a 400. See profiles.md. |
network |
object | Omit or null for no network (default-deny). See below. |
image |
object | Boot from an OCI image instead of a profile: {"oci": "nginx:alpine"} (a registry ref or a converted digest). The daemon pulls + converts on a store miss and runs the image's entrypoint as the sandbox's service. Mutually exclusive with profile. |
pull |
string | Image pull policy when image.oci is set: "missing" (default — convert/cache on a store miss), "always" (re-pull even on a hit), "never" (fail if not already converted). Ignored when image is unset. |
publish |
array | Host→guest port publishes, e.g. [{"host_port":8080,"guest_port":80}] (optional host_ip, default protocol "tcp") — the moral equivalent of docker run -p. Requires a NIC; when network is absent one is created ingress-published, egress-denied. |
disk_bytes |
int | Grow the writable rootfs clone to at least this size (truncate + resize2fs, before boot). 0/omitted keeps the image/profile headroom; a value smaller than the clone is a no-op (never shrinks). The shared template is never modified. |
Network policy. When network is present, enabled: true requires a non-empty allowlist; unrestricted full-internet egress is not offered by design (default-deny is the model). Each allowlist entry is an exact hostname (pypi.org) or a single-label wildcard (*.npmjs.org); a bare * is rejected. enabled: false with a populated allowlist is a 400 (inconsistent).
Response 201 Created:
{
"id": "sbx_7k2m...",
"vcpus": 1,
"memory_mib": 512,
"workdir": "/tmp/crucible/run/sbx_7k2m...",
"created_at": "2026-01-01T00:00:00Z",
"network": {
"enabled": true,
"guest_ip": "10.20.0.2",
"gateway": "10.20.0.1",
"allowlist": ["pypi.org", "*.npmjs.org"]
}
}network is omitted when the sandbox has no NIC; profile is echoed back when the sandbox booted from a named profile. Errors: 400 (invalid JSON or config, including an unknown profile), 501 (image set), 500 (boot failure).
GET /sandboxes
Returns 200 with {"sandboxes": [ <sandbox>, ... ]} (empty array when none). Each element has the same shape as the create response.
GET /sandboxes/{id}
Returns 200 with the sandbox object, 404 if unknown, 400 on a malformed ID.
DELETE /sandboxes/{id}
Tears down the VM and reclaims its resources. Returns 204 No Content on success, 404 if unknown.
POST /sandboxes/{id}/exec
Run a command inside the sandbox and stream its output. Request body:
{
"cmd": ["pytest", "/app/tests"],
"env": { "CI": "1" },
"cwd": "/app",
"timeout_s": 120
}| 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 = an 8-byte header (1 type byte, 3 reserved bytes, a big-endian
uint32payload length) followed by the payload. - Response frame types:
1= stdout,2= stderr,3= exit. - stdout/stderr data frames arrive as the command runs; the final
exitframe's payload is theExecResultJSON.
Because the 200 is committed before the command finishes, a failure after streaming starts (agent unreachable, dropped connection) is delivered in-band as an exit frame with exit_code: -1 and a populated error — never as an HTTP error code.
Interactive exec (POST /sandboxes/{id}/exec?stdin=1). The one-shot path above is buffered. For a live shell, the daemon 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. The one-shot path is unchanged.
Interactive exec over WebSocket (GET /sandboxes/{id}/exec + upgrade handshake). The same session for clients that can't 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 — same byte layout, same frame types, so one frame codec serves both transports. Frames may split across WebSocket messages: decode the concatenated stream, not individual messages. 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_codeis-1when the process was killed by a signal or never started.signalis the signal name (e.g."SIGKILL") when the process was signalled; empty on a clean exit.oom_killedis a best-effort heuristic (SIGKILL not from our timeout + peak RSS ≥ 95% of guest memory), not a precise cgroup reading.usageisnullwhen the agent collected no stats (e.g. the process never started), so callers can tell "no data" from "zeroes". I/O counters are per-process and approximate for sub-100 ms commands (see the field docs insdk/wire/exec.go).
POST /sandboxes/{id}/files and GET /sandboxes/{id}/files
File transfer between host and guest — one-way bulk copy, so no frame protocol.
Push (POST …/files?path=<dest>): the request body is a tar stream. The daemon streams it straight to the guest agent (nothing buffered whole); the agent MkdirAlls <dest> and extracts each entry beneath it, rejecting any entry whose resolved path escapes the destination (absolute paths, .., or symlinks pointing outside). Returns 200 {"files":N,"bytes":M}. Gated as an exec-class operation. This backs crucible cp (push) and the MCP write_files tool.
Read (GET …/files?path=<file>&max_bytes=<n>): returns the raw bytes of a single guest file (capped by max_bytes; a directory is a 400). This is a content read — only bytes flow out, nothing is written host-side, so it has no path-traversal surface. Gated as read. It backs the MCP read_file tool. Pulling a directory tree onto the host is intentionally not offered.
POST /sandboxes/{id}/snapshot
Pause the sandbox and write a snapshot (a VM state file + a guest-memory file) to disk. Returns 201 with:
{
"id": "snp_...",
"source_id": "sbx_7k2m...",
"vcpus": 1,
"memory_mib": 512,
"dir": "/tmp/crucible/run/.../snap",
"state_path": ".../state",
"mem_path": ".../mem",
"rootfs_path": ".../rootfs.ext4",
"created_at": "2026-01-01T00:00:00Z"
}The on-disk paths are returned for operator debugging (ls, du, debugfs). Errors: 404 (unknown sandbox), 500.
GET /snapshots / GET /snapshots/{id} / DELETE /snapshots/{id}
List ({"snapshots": [...]}), inspect (200 / 404), and delete (204 / 404) snapshots. Same object shape as the snapshot response above.
POST /snapshots/{id}/fork
Create sandboxes from a snapshot. Fan-out is set by the ?count=N query parameter (default 1); N must be a positive integer and is capped by the daemon's max fork count (400 if exceeded).
Fork is all-or-nothing: if any child fails to come up, every child started so far is rolled back and the call returns an error. On success, returns 201:
{ "sandboxes": [ <sandbox>, <sandbox>, ... ] }Each child is a fully independent sandbox (its own ID, network, and — thanks to clone-safety — its own fresh RNG state and machine identifiers). Errors: 400 (bad count or invalid config), 404 (unknown snapshot), 500.
Example session
# create
SBX=$(curl -s -XPOST localhost:7878/sandboxes -d '{"memory_mib":1024}' | jq -r .id)
# run setup
curl -s -XPOST localhost:7878/sandboxes/$SBX/exec \
-d '{"cmd":["sh","-lc","git clone https://github.com/x/y /app && cd /app && pip install -r requirements.txt"]}' --output -
# snapshot after setup
SNP=$(curl -s -XPOST localhost:7878/sandboxes/$SBX/snapshot | jq -r .id)
# fork 4 children from the warm snapshot
curl -s -XPOST "localhost:7878/snapshots/$SNP/fork?count=4" | jq '.sandboxes[].id'The exec response is a binary frame stream — specified language-neutrally in wire.md, with conformance fixtures under sdks/fixtures (see sdk/wire for the reference reader/writer). The Python SDK on the roadmap wraps all of this.