# spawnd — Full API Documentation > spawnd is an AI sandbox hosting platform. Agents and applications programmatically > spin up secure Firecracker microVMs, run untrusted code inside them over a REST API, > and are billed by the second of allocated VM usage (resource-seconds). This document is > the complete, self-contained reference: authentication, every endpoint with request and > response shapes plus curl examples, sandbox sizes, the lifecycle state machine, billing, > the error taxonomy, the CLI, and the MCP server. - Base API domain: `spawnd.oonamy.xyz` - Preview URL domain: `.spawnd.oonamy.xyz` - API version prefix: `/v1` - Auth: org API key (`Authorization: Bearer sk_live_...`) for the SDK/sandbox API; a session JWT for dashboard/key-management calls. - Content type: `application/json` for all request and response bodies. --- ## What is spawnd spawnd is the infrastructure that sits behind someone else's agent. The primary consumer is a **machine** — an AI agent or app driving sandboxes at scale — not a human clicking around. A sandbox is a secure Firecracker microVM running a generic Ubuntu rootfs with common per-language toolchains preinstalled (Node + npm, Python + pip, Go, Rust/cargo, plus standard build utilities). Untrusted guest code is isolated by construction (microVM + jailer + a per-VM network namespace); it cannot reach the host, the control plane, or another sandbox. Sandboxes are reachable four ways: 1. **REST / SDK API** — the primary surface. Create sandboxes, exec commands, read/write files, expose ports, and drive the full lifecycle. 2. **SSH / interactive terminal** — a browser terminal or SSH for human sessions. 3. **Preview URLs** — expose a guest port publicly via reverse proxy (`.spawnd.oonamy.xyz`). 4. **MCP server** — the orchestrator exposes sandbox control as MCP tools so LLM clients can drive sandboxes directly. --- ## Authentication There are two disjoint auth surfaces on the same port. ### Org API key (SDK / sandbox API) All sandbox and usage calls (`/v1/sandboxes`, `/v1/usage`) authenticate with an org API key: ``` Authorization: Bearer sk_live_... ``` Keys are stored server-side as a sha256 hash plus a 20-character `prefix` — the full secret is returned exactly once at creation and never again. A key authenticates the `/v1/*` sandbox API but cannot manage keys or the human session. ### Session JWT (dashboard / key management) Human/dashboard calls (`/auth/*`, `/v1/keys`) authenticate with a stateless HS256 session JWT issued by the passwordless magic-link flow: ``` Authorization: Bearer ``` A session JWT cannot drive the sandbox SDK, and an API key cannot manage keys — the two surfaces are disjoint. ### Passwordless sign-in (magic link) There are no passwords. A user submits an email; the orchestrator find-or-creates the org and user, mints a single-use short-lived token (default 15 min, only its sha256 hash is stored), and delivers a link `/auth/verify?token=...`. Email delivery is pluggable: with a provider configured the link is emailed; with no provider the service runs in **dev mode** and returns the link in the response (`devLink`) and logs — it is never dropped. Verifying the token issues a session JWT. The console then auto-provisions the org's default API key on first authenticated load, so a human never pastes or manages a key by hand. --- ## Quickstart Get from zero to a running sandbox with curl. **1. Request a magic link.** In dev mode the link comes back in the response. ```bash curl -s -X POST https://spawnd.oonamy.xyz/auth/magic-link \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com"}' # → { "sent": true, "email": "you@example.com", "expiresInMinutes": 15, # "devLink": "https://spawnd.oonamy.xyz/auth/verify?token=..." } ``` **2. Verify the token to get a session JWT.** ```bash curl -s 'https://spawnd.oonamy.xyz/auth/verify?token=THE_TOKEN' # → { "token": "", "user": {...}, "org": {...} } ``` **3. Mint an org API key** (session-authed). The full `key` is shown once. ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/keys \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"name":"cli"}' # → { "key": "sk_live_...", "keyRecord": { "id": "...", "prefix": "sk_live_...", ... } } ``` **4. Create a sandbox** with the API key. ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"template":"base"}' # → { "id": "sbx_...", "state": "running", "template": "base", ... } ``` **5. Exec a command inside it.** ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/exec \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"cmd":"echo","args":["hello from the box"]}' # → { "exitCode": 0, "stdout": "hello from the box\n", "stderr": "", "durationMs": 42 } ``` **6. Kill it** when done (idempotent — billing stops). ```bash curl -s -X DELETE https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123 \ -H 'Authorization: Bearer sk_live_...' # → { "ok": true } ``` --- ## Concepts ### Sandboxes A sandbox is one Firecracker microVM. It is created from a template (a size preset), boots to a `running` state once its in-VM guest agent answers a health ping, and is torn down on `kill`. Every sandbox belongs to an org and is only reachable with that org's API key. ### Sizes `template` accepts a named size preset (RAM GiB = vCPU × 2). Allocation (vCPU + memory) is fixed at create time and is the basis for resource-seconds billing. An unknown name is rejected with `invalid_argument`; omitting `template` gives you `box`. | Name | vCPU | Memory | Use for | |--------------------|------|-----------|-------------------------------------------| | `smallbox` / `base`| 1 | 2048 MiB | light scripts, quick execs, cheapest | | `box` / `default` | 2 | 4096 MiB | the default; general-purpose agent work | | `bigbox` | 4 | 8192 MiB | heavy compilation, parallel workloads | | `megabox` | 8 | 16384 MiB | the largest box; big parallel builds | ### Lifecycle Every sandbox supports the full lifecycle. Each verb is idempotent — `kill` on an already-dead sandbox succeeds, `pause` on a paused VM is a no-op, and `create` with the same idempotency key returns the same sandbox. | Verb | Meaning | |------------|-----------------------------------------------------------------------------| | `create` | Allocate a sandbox from a template; resumes from the warm pool when it can. | | `start` | Bring a created sandbox to `running`. | | `exec` | Run a command inside the guest and get its stdout/stderr/exit code. | | `snapshot` | Capture full VM state (memory + disk) as a restorable artifact. | | `fork` | Copy-on-write clone from a snapshot — cheap children from one parent state. | | `pause` | Snapshot memory + state and freeze CPU; billing drops to the paused rate. | | `resume` | Un-freeze a paused VM back to `running`. | | `stop` | Graceful shutdown. | | `kill` | Hard teardown; release every host resource immediately. Terminal. | Cold starts use snapshot-restore from a warm pool so `create` / `resume` is sub-second; a full kernel boot is the fallback path, not the target. ### State machine Legal states: `creating`, `running`, `paused`, `stopping`, `stopped`, `killed`. Plus transient `snapshotting` and `forking` reported during those operations. `killed` is terminal and irreversible. Every transition is host-observed and written by the control plane; the guest cannot change its own sandbox's state. ``` create (none) ─────────────────▶ creating │ boot + guest ping ok ▼ ┌──────────── pause ◀── running ──▶ stopping ──▶ stopped │ ▲ │ (graceful) │ paused ── resume ──────────┘ │ snapshot │ resume/start │ ▼ │ │ (snapshot taken, stays running) │ └──────────────── kill ─────┴──────── kill ───────────┘ ──▶ killed (terminal) fork(snapshot) ─────────▶ creating (a NEW sandbox id) ``` ### Isolation Guest code is hostile by default. Each VM boots under `jailer` (chroot + dropped privileges) in its own network namespace with a per-VM tap device and double-NAT'd outbound internet. From inside a sandbox there is no route to the host, no route to the control plane, and no route to another sandbox; `/dev/kvm` and host files are unreachable. Isolation is enforced by construction, not by trusting the guest. ### Billing — resource-seconds spawnd bills **allocated resource-seconds**, not flat wall-clock, so cost scales with size. Two dimensions, both derived from the VM's create-time allocation multiplied by time: - **vCPU-seconds** = `vcpu × seconds` - **GiB-seconds** = `(memMib / 1024) × seconds` Allocation is fixed at `create`, so billing is deterministic and unforgeable — the guest cannot change its own `vcpu` / `memMib`, and actual footprint (`cpuNs`, `rssBytes`) is measured for capacity and observability but never changes the bill. | VM state | Billed | Rate class | |-----------------------------|----------------------------------------------|---------------------| | `running` | vCPU-s + GiB-s, live | `active` | | `snapshotting`, `forking` | live (it is still running) | `active` | | `paused` | vCPU-s + GiB-s at the paused rate | `paused` (cheaper) | | `creating` | not billed until the guest ping succeeds | — | | `stopped`, `killed` | not billed | — | The `usage_ledger` is append-only, one row per contiguous state interval: `{ id, orgId, sandboxId, rateClass, startedAt, endedAt, seconds, vcpu, memMib, source }`. Seconds are derived from host heartbeat wall-clock deltas with a monotonic clock, never guest time. A bill is the sum over rows by rate class. --- ## REST API reference Base URL `https://spawnd.oonamy.xyz`. All bodies are JSON. Auth is noted per group. ### Auth #### POST /auth/magic-link Find-or-create the org and user, mint a single-use token, and send the sign-in link. `devLink` is present only in dev mode. A malformed email returns `409`. - Auth: none - Request: `{ "email": string }` - Response: `{ "sent": boolean, "email": string, "expiresInMinutes": number, "devLink"?: string }` ```bash curl -s -X POST https://spawnd.oonamy.xyz/auth/magic-link \ -H 'Content-Type: application/json' \ -d '{"email":"you@example.com"}' ``` #### GET /auth/verify?token= Consume the single-use token (must be unexpired and unused) and issue a session JWT. Invalid, used, or expired tokens return `401`. - Auth: none (the token is the credential) - Query: `token` — the magic-link token - Response: `{ "token": string, "user": {...}, "org": {...} }` ```bash curl -s 'https://spawnd.oonamy.xyz/auth/verify?token=THE_TOKEN' ``` #### GET /auth/me Return the current profile for a session. Missing, invalid, or expired token returns `401`. - Auth: session JWT - Response: `{ "user": {...}, "org": {...} }` ```bash curl -s https://spawnd.oonamy.xyz/auth/me \ -H 'Authorization: Bearer ' ``` ### API keys #### GET /v1/keys List the org's keys. The secret is never returned. - Auth: session JWT - Response: `ApiKey[]` where `ApiKey = { id, prefix, name, createdAt, lastUsedAt, revokedAt }` ```bash curl -s https://spawnd.oonamy.xyz/v1/keys \ -H 'Authorization: Bearer ' ``` #### POST /v1/keys Mint a new `sk_live_...` key. The full `key` is returned exactly once. - Auth: session JWT - Request: `{ "name"?: string }` - Response: `{ "key": string, "keyRecord": ApiKey }` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/keys \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"name":"ci"}' ``` ### Sandboxes All sandbox endpoints authenticate with an org API key (`Authorization: Bearer sk_live_...`) and are scoped to the caller's org. A sandbox from another org returns `404` (`not_found`). #### POST /v1/sandboxes Allocate and start a sandbox. Resumes from the warm pool when possible. Pass an `idempotencyKey` to make retries return the same sandbox. - Request: `{ "template": string, "metadata"?: object, "idempotencyKey"?: string }` - Response: `Sandbox` - `template` is a named size (`smallbox`/`base`, `box`/`default`, `bigbox`, `megabox`); an unknown name is rejected with `invalid_argument`. ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"template":"bigbox","metadata":{"job":"build-42"}}' ``` #### GET /v1/sandboxes List the caller's org sandboxes, newest first. - Response: `Sandbox[]` ```bash curl -s https://spawnd.oonamy.xyz/v1/sandboxes \ -H 'Authorization: Bearer sk_live_...' ``` #### GET /v1/sandboxes/:id Get one sandbox's current state. - Response: `Sandbox` ```bash curl -s https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123 \ -H 'Authorization: Bearer sk_live_...' ``` #### POST /v1/sandboxes/:id/exec Run a command inside the guest over vsock and return its result. - Request: `{ "cmd": string, "args"?: string[], "cwd"?: string, "env"?: object, "timeoutMs"?: number }` - Response: `{ "exitCode": number, "stdout": string, "stderr": string, "durationMs": number }` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/exec \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"cmd":"python3","args":["-c","print(2+2)"]}' ``` #### POST /v1/sandboxes/:id/files Write a file into the guest. Contents are base64-encoded. - Request: `{ "path": string, "contentB64": string }` - Response: `{ "bytes": number }` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/files \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"path":"/root/app.py","contentB64":"'"$(printf 'print(1)' | base64)"'"}' ``` #### GET /v1/sandboxes/:id/files?path= Read a file from the guest. Contents come back base64-encoded. - Query: `path` — absolute path inside the guest - Response: `{ "contentB64": string }` ```bash curl -s 'https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/files?path=/root/app.py' \ -H 'Authorization: Bearer sk_live_...' ``` #### POST /v1/sandboxes/:id/ports Expose a guest port publicly. Returns a preview URL at `.spawnd.oonamy.xyz`. - Request: `{ "port": number }` - Response: `{ "url": string }` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/ports \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"port":8000}' # → { "url": "http://sbx_123.spawnd.oonamy.xyz" } ``` #### POST /v1/sandboxes/:id/pause Snapshot and freeze the VM. Billing drops to the paused rate; memory stays resident. - Response: `Sandbox` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/pause \ -H 'Authorization: Bearer sk_live_...' ``` #### POST /v1/sandboxes/:id/resume Restore a paused VM back to `running` from its pause snapshot. - Response: `Sandbox` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/resume \ -H 'Authorization: Bearer sk_live_...' ``` #### POST /v1/sandboxes/:id/snapshot Take a named, restorable full-state snapshot (memory + disk). The VM stays running. - Request: `{ "name"?: string }` - Response: `Snapshot` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/snapshot \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"name":"after-deps"}' ``` #### POST /v1/sandboxes/:id/fork Copy-on-write clone from a snapshot into a fresh sandbox (a new id). Omit `snapshotId` to fork from the latest snapshot. - Request: `{ "snapshotId"?: string }` - Response: `Sandbox` (the new child) ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/fork \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"snapshotId":"snap_abc"}' ``` #### POST /v1/sandboxes/:id/stop Gracefully shut down the sandbox. - Response: `Sandbox` ```bash curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/stop \ -H 'Authorization: Bearer sk_live_...' ``` #### DELETE /v1/sandboxes/:id Kill the sandbox and reap all of its host resources. Idempotent — killing an already-dead sandbox still returns `{ "ok": true }`. - Response: `{ "ok": boolean }` ```bash curl -s -X DELETE https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123 \ -H 'Authorization: Bearer sk_live_...' ``` ### Usage #### GET /v1/usage Return the org's resource-seconds (vCPU-s + GiB-s) over a time window. - Query: `from`, `to` — ISO timestamps bounding the window - Response: `UsageSummary` ```bash curl -s 'https://spawnd.oonamy.xyz/v1/usage?from=2026-08-01T00:00:00Z&to=2026-08-07T00:00:00Z' \ -H 'Authorization: Bearer sk_live_...' ``` --- ## Error taxonomy Every API error is `{ "code": string, "message": string, "retryable": boolean }`. | Code | Meaning | Typical HTTP | |------------------|------------------------------------------------------|--------------| | `unauthorized` | Missing, invalid, or wrong-surface credential | 401 | | `not_found` | No such sandbox/resource for this org | 404 | | `invalid_state` | The verb is illegal for the sandbox's current state | 409 | | `quota_exceeded` | Org quota reached | 429 | | `capacity` | No host slot right now (retryable) | 503 | | `guest_timeout` | The guest did not respond in time | 504 | | `internal` | Unexpected server error | 500 | Only `capacity` is generally worth retrying (`retryable: true`). --- ## CLI The `spawn` CLI wraps the same REST API. Authenticate once with your org API key, then drive sandboxes from the terminal. (Exact flags may evolve; these are the intended commands.) | Command | Does | |-------------------------------|----------------------------------------------------------| | `spawn smallbox` | Create and enter a `smallbox` (1 vCPU / 2048 MiB). | | `spawn bigbox` | Create and enter a `bigbox` (4 vCPU / 8192 MiB). | | `spawn ls` | List your org's sandboxes. | | `spawn exec -- ` | Run a command inside a sandbox and stream the result. | | `spawn fork ` | Copy-on-write clone a sandbox from its latest snapshot. | | `spawn rm ` | Kill a sandbox and reap its resources. | ```bash # create a bigbox, run a build, then tear it down spawn bigbox spawn exec sbx_123 -- cargo build --release spawn rm sbx_123 ``` Any size name (`smallbox`, `base`, `box`, `bigbox`, `megabox`) works as a `spawn ` command. --- ## MCP server The orchestrator exposes sandbox control as an MCP (Model Context Protocol) server, so any MCP client — an IDE, an agent framework, an LLM app — can drive sandboxes as tools. Register it with your MCP client using the server URL and your org API key as the bearer credential. Example client registration: ```json { "mcpServers": { "spawnd": { "url": "https://spawnd.oonamy.xyz/mcp", "headers": { "Authorization": "Bearer sk_live_..." } } } } ``` Tools map 1:1 to the lifecycle: | Tool | Does | |---------------------|---------------------------------------------------| | `spawn_sandbox` | Create a sandbox from a size template. | | `exec` | Run a command inside a sandbox. | | `write_file` | Write a file into the guest. | | `read_file` | Read a file from the guest. | | `expose_port` | Expose a guest port and get a preview URL. | | `snapshot_sandbox` | Take a named snapshot. | | `fork_sandbox` | Copy-on-write clone from a snapshot. | | `pause_sandbox` | Pause a running sandbox. | | `resume_sandbox` | Resume a paused sandbox. | | `kill_sandbox` | Kill a sandbox and reap resources. | --- ## Preview URLs Expose a guest port to the public internet with `POST /v1/sandboxes/:id/ports`. The response `url` is `http://.spawnd.oonamy.xyz`, routed by the HTTP `Host` header to the guest port. Previews are org-scoped by construction — only the owning org can create a mapping, and a preview never outlives its VM (stopping or killing the sandbox deregisters it). Because routing is by Host header, a preview is testable locally: ```bash curl -H 'Host: sbx_123.spawnd.oonamy.xyz' http://127.0.0.1:8080/ ``` A typical flow: start a dev server inside the sandbox, expose its port, and share the URL. ```bash spawn exec sbx_123 -- python3 -m http.server 8000 & curl -s -X POST https://spawnd.oonamy.xyz/v1/sandboxes/sbx_123/ports \ -H 'Authorization: Bearer sk_live_...' \ -H 'Content-Type: application/json' \ -d '{"port":8000}' # → { "url": "http://sbx_123.spawnd.oonamy.xyz" } ```