# Control plane API (legacy)

> Internal /sandbox* routes: create/list/get, exec, keepalive, wake JSON, per-sandbox snapshots, purge/claim, healthz/readyz, metrics, GET /llm.txt integrator contract.

- Repository: tastyeffectco/sandboxes
- GitHub: https://github.com/tastyeffectco/sandboxes
- Human docs: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0
- Complete Markdown: https://grok-wiki.com/public/docs/tastyeffectco-sandboxes-f551c1a2e9a0/llms-full.txt

## Source Files

- `control-plane/internal/api/api.go`
- `control-plane/internal/api/handlers.go`
- `control-plane/internal/api/llmtxt.go`
- `AGENTS.md`
- `control-plane/README.md`
- `control-plane/internal/api/v1_snapshots.go`

---

---
title: "Control plane API (legacy)"
description: "Internal /sandbox* routes: create/list/get, exec, keepalive, wake JSON, per-sandbox snapshots, purge/claim, healthz/readyz, metrics, GET /llm.txt integrator contract."
---

The `sandboxd` HTTP server in `control-plane/internal/api` registers legacy `/sandbox*` routes on the same listener as `/v1/*`, wake catch-all preview traffic, and ops endpoints. Requests hit `hostDispatch` first (preview `Host` → wake HTML); everything else goes to the API mux, wrapped in optional service-token auth and per-route Prometheus counters (`sandboxd_api_requests_total`, `sandboxd_api_request_duration_seconds`). Legacy handlers return a flat JSON error envelope `{"error":"<message>"}` (or richer maps for a few conflict cases); integrators building new features should prefer `/v1/sandboxes` (structured `code` / `message` / `retryable`).

## Base URL and routing

| Surface | Default bind | Typical caller |
| --- | --- | --- |
| Control plane API | `127.0.0.1:9090` (`SANDBOXED_API_BIND`) | `curl`, automation on the host |
| Preview wake (HTML) | Traefik `HTTP_PORT` with `Host: s-{ulid}-{port}.preview.{PREVIEW_DOMAIN}` | Browser |

```text
  Client                    sandboxd (one listener)
     |                              |
     |  Host matches preview?       |
     +----------------------------->| hostDispatch -> wake.ServeCatchAll (HTML)
     |                              |
     |  else                        |
     +----------------------------->| auth.Wrap -> api.Server.Handler()
```

<Note>
Loopback clients (`RemoteAddr` is loopback and no `X-Forwarded-For`) skip bearer auth. Traefik-forwarded calls are treated as external and require `Authorization: Bearer <secret>` when `SANDBOXD_API_AUTH_DISABLED=false`.
</Note>

## Legacy route inventory

| Method | Path | Role |
| --- | --- | --- |
| `POST` | `/sandbox` | Create sandbox row + container |
| `GET` | `/sandboxes` | List sandboxes (optional filters) |
| `GET` | `/sandbox/{id}` | Row + Docker inspect + runtimed status |
| `DELETE` | `/sandbox/{id}` | Destroy container; **keep** workspace |
| `POST` | `/sandbox/{id}/exec` | Non-interactive `docker exec` |
| `POST` | `/sandbox/{id}/keepalive` | Extend idle-reaper exemption |
| `POST` | `/wake/{id}` | Programmatic wake (JSON) |
| `POST` | `/sandbox/{id}/snapshots` | Manual workspace snapshot |
| `GET` | `/sandbox/{id}/snapshots` | List on-disk snapshots |
| `POST` | `/sandbox/{id}/restore` | Restore snapshot to live `.img` |
| `POST` | `/sandbox/{id}/claim` | Attach upstream external identity |
| `POST` | `/sandbox/{id}/purge` | Irreversible teardown + disk delete |
| `POST` | `/external-users/{external_user_id}/purge` | Purge all sandboxes for user |
| `POST` | `/external-projects/{external_project_id}/purge` | Purge all sandboxes for project |
| `GET` | `/healthz` | Liveness (`ok`) |
| `GET` | `/readyz` | Readiness (SQLite + `docker info`) |
| `GET` | `/metrics` | Prometheus scrape (loopback-only when auth on) |
| `GET` | `/llm.txt` | Public integrator contract (no token) |

## Shared response shapes

### Sandbox row (`sandboxResp`)

Returned by create, list, and embedded in get as `row`:

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | ULID |
| `status` | string | `creating`, `running`, `stopped`, `error`, … |
| `image` | string | Base image tag |
| `workspace_img`, `workspace_mnt` | string | Host paths |
| `container_id`, `cgroup_path` | string | When running |
| `memory_high` | string | Soft cgroup limit (default `4G` on create) |
| `ports` | int[] | Exposed preview ports |
| `last_active_at`, `stopped_at`, `keepalive_until` | int64 | Unix seconds |
| `container_ip` | string | Bridge IP when running |
| `external_user_id`, `external_project_id`, `external_workspace_id` | string | Phase 8 identity |
| `visibility` | string | `public` (default) or `private` |

### GET `/sandbox/{id}` envelope (`getResp`)

```json
{
  "row": { },
  "live_state": { },
  "runtime": { }
}
```

- `live_state` — Docker inspect JSON when container `s-{id}` exists; omitted on inspect miss.
- `runtime` — `runtimed` `GET /status` over `<workspace>.mnt/.runtimed/sock` (3s timeout). Absent when stopped or runtimed unreachable.

## Sandbox lifecycle endpoints

:::endpoint POST /sandbox
Create a sandbox: SQLite row (`creating` → provision workspace → `docker run` → `running`), Traefik labels, optional env injection.

<ParamField body="ports" type="int[]">
Preview ports (1–65535). Drives `s-{id}-{port}.preview.{domain}` routers.
</ParamField>

<ParamField body="id" type="string">
Optional ULID; auto-generated if omitted. Must be valid ULID when set.
</ParamField>

<ParamField body="memory_high" type="string">
Cgroup soft limit; default `4G`.
</ParamField>

<ParamField body="visibility" type="string">
`public` or `private`.
</ParamField>

<ParamField body="external" type="object">
`user_id` (required for strict multi-tenant; defaults to `"local"` if empty), optional `project_id`, `workspace_id`. IDs ≤256 chars, no control chars or commas.
</ParamField>

<ParamField body="template" type="string">
Golden template name (`[a-z0-9-]`, ≤64). Requires `SANDBOXD_TEMPLATES_DIR` and `{name}.img` on disk.
</ParamField>

<ParamField body="env" type="object">
Container env map (e.g. provider API keys). Keys must not contain `=` or newlines.
</ParamField>

<ParamField body="git_remote_url" type="string">
HTTPS remote for auto-git-push on task finish.
</ParamField>

<ResponseField name="body" type="sandboxResp">
`201 Created` with full row including `container_id` when successful.
</ResponseField>

**Errors:** `400` invalid JSON/ports/ULID/visibility/env/template; `409` row exists or `workspace_owner_mismatch` on id reuse; `503` memory admission refused (`Retry-After: 30`, `mem_available_percent` in body).
:::

<RequestExample>

```bash
curl -s -X POST http://127.0.0.1:9090/sandbox \
  -H 'Content-Type: application/json' \
  -d '{"ports":[3000],"env":{"ANTHROPIC_API_KEY":"sk-ant-..."}}'
```

</RequestExample>

:::endpoint GET /sandboxes
List all sandbox rows, or filter with query params `external_user_id` and/or `external_project_id`.
:::

:::endpoint GET /sandbox/{id}
Fetch one sandbox with optional `live_state` and `runtime`. `404` if no SQLite row.
:::

:::endpoint DELETE /sandbox/{id}
Stop/remove container `s-{id}`, release loopback mount, delete SQLite row. Workspace `.img` **remains** for id reuse. `204 No Content` on success.
:::

<Tip>
Use `DELETE /sandbox/{id}` before manual snapshot/restore while status is `running`; snapshot endpoints return `409` for a running row.
</Tip>

## Exec and keepalive

:::endpoint POST /sandbox/{id}/exec
Run a command inside the sandbox via `docker exec` (no TTY/stdin).

<ParamField body="cmd" type="string[]" required>
Argv passed to exec.
</ParamField>

<ParamField body="stream" type="boolean">
If true: `200` chunked `text/plain` with stdout, optional `---stderr---`, and `exit_code: N`. Default false returns JSON `execResp`.
</ParamField>

Bumps `last_active_at` on entry and exit; counts toward idle-reaper activity when `Inflight` is wired.
:::

<RequestExample>

```bash
curl -s -X POST http://127.0.0.1:9090/sandbox/$ID/exec \
  -H 'Content-Type: application/json' \
  -d '{"cmd":["bash","-lc","python3 -m http.server 3000"]}'
```

</RequestExample>

:::endpoint POST /sandbox/{id}/keepalive
Postpone idle stop until a wall-clock instant.

<ParamField body="until" type="int64" required>
Unix seconds; must be in the future. Capped at now + `KeepaliveMax` (default 24h).
</ParamField>

<ResponseExample>

```json
{"id":"01J...","keepalive_until":1717531200}
```

</ResponseExample>
:::

## Wake (JSON)

:::endpoint POST /wake/{id}
Start or confirm a stopped sandbox without a preview `Host` header. Delegates to `wake.Handler.ServeJSON` (same core as preview wake, JSON response shape).

<ResponseField name="body" type="object">
`200`: `{"id":"...","status":"running","wake_duration_ms":N}`.
</ResponseField>

**Errors:** `404` `{"error":"not_found"}`; `503` with `error` and optional `mem_available_percent` (admission); `Retry-After: 30` on admission denial.
:::

<Info>
Browser preview traffic uses the catch-all wake path (HTML warming page), not `POST /wake/{id}`. See the preview-routing and wake-idle-reapers pages.
</Info>

## Per-sandbox snapshots (`_snapshots/`)

Distinct from tenant-scoped `POST /v1/snapshots` library templates. These endpoints operate on `_snapshots/<id>/` zstd archives; they do **not** require a DB row for list/take (except running-row guard).

:::endpoint POST /sandbox/{id}/snapshots
Manual snapshot. `202 Accepted` with `ts` and `size_bytes` (compressed). `409` if status is `running`; `404` if no workspace `.img`; `503` if snapshot subsystem unset.
:::

:::endpoint GET /sandbox/{id}/snapshots
Array of `{ts, size_bytes, compressed_size_bytes, auto}` newest-first; `[]` if directory missing.
:::

:::endpoint POST /sandbox/{id}/restore
Body: `{"snapshot":"<YYYY-MM-DD-HHMMSS>"}` (timestamp from list). `200` with `size_bytes`, `restored_at`. `409` running; `404` unknown snapshot.
:::

## Claim and purge

:::endpoint POST /sandbox/{id}/claim
Move a sandbox (often legacy/back-filled) to a real upstream identity. Updates `sandbox` and durable `workspace_owner`.

<ParamField body="external_user_id" type="string" required>
New owner id (same validation as create `external`).
</ParamField>

<ResponseExample>

```json
{"id":"01J...","external_user_id":"user-42","claimed":true}
```

</ResponseExample>
:::

:::endpoint POST /sandbox/{id}/purge
Irreversible: stop/remove container, release loopback, delete workspace `.img`, `_snapshots/<id>/`, and SQLite `sandbox` + `workspace_owner` rows (per-id lock held throughout).

<ResponseExample>

```json
{"purged":true,"freed_bytes":12345678}
```

</ResponseExample>
:::

:::endpoint POST /external-users/{external_user_id}/purge
Purge every sandbox owned by that external user. Stops on first failure after auditing successful purges.

<ResponseField name="purged_count" type="int">
Number of sandboxes removed.
</ResponseField>

<ResponseField name="freed_bytes" type="int64">
Aggregated allocated disk freed.
</ResponseField>
:::

:::endpoint POST /external-projects/{external_project_id}/purge
Same as user purge, scoped by `external_project_id`.
:::

<Warning>
`DELETE /sandbox/{id}` preserves disk; `POST /sandbox/{id}/purge` deletes it. There is no undo for purge.
</Warning>

## Health, readiness, and metrics

:::endpoint GET /healthz
Always `200` with body `ok\n`. No dependency checks.
:::

:::endpoint GET /readyz
`200` + `ready\n` only if SQLite pings and `docker info` succeeds. Otherwise `503` JSON `{"error":"sqlite ping: ..."}` or `{"error":"docker info: ..."}`.
:::

:::endpoint GET /metrics
Prometheus exposition from `metrics.Registry` (API counters/histograms, sandbox gauges, reaper/wake metrics, docker timings). When auth middleware is active, external requests receive `404`; scrape from loopback on `SANDBOXED_API_BIND`.
:::

## GET /llm.txt — integrator contract

Public, tokenless API documentation for third-party integrators and coding agents.

| Property | Behavior |
| --- | --- |
| Path | `GET /llm.txt` |
| Auth | Listed in `exemptPaths`; no bearer required |
| Source file | Host path `SANDBOXD_LLM_TXT_PATH` (default `/etc/sandboxed/llm.txt`) |
| Cache | `Cache-Control: public, max-age=300` |
| Missing | `404` if env path empty or file absent |
| Reload | Read per request (no redeploy needed for edits) |

<Note>
The repository does not ship a default `llm.txt` file. Operators mount or copy the contract onto the host at the configured path. The v1 layer references sections of this contract (e.g. backpressure `Retry-After` on create `503`).
</Note>

<RequestExample>

```bash
curl -s http://127.0.0.1:9090/llm.txt
```

</RequestExample>

## Authentication quick reference

| Path class | Bearer required (auth enabled) |
| --- | --- |
| `/sandbox*`, `/wake/*`, purge, claim | Yes (external) |
| `/healthz`, `/readyz`, `/llm.txt` | No |
| `/metrics` | Loopback only |
| Direct `127.0.0.1:9090` curl | No (loopback operator path) |

Configure `SANDBOXD_API_TOKENS=name:secret` and `SANDBOXD_API_AUTH_DISABLED=false` for LAN or production exposure.

## Legacy vs v1

| Concern | Legacy | v1 |
| --- | --- | --- |
| Create | `POST /sandbox` | `POST /v1/sandboxes` |
| Stop (idle) | — | `POST /v1/sandboxes/{id}/stop` |
| Destroy (keep disk) | `DELETE /sandbox/{id}` | `DELETE /v1/sandboxes/{id}` |
| Purge disk | `POST /sandbox/{id}/purge` | Delegates to same purge handler |
| Tasks / files | — | `/v1/sandboxes/{id}/tasks`, `/files` |
| Reusable templates | `template` on create | `POST /v1/snapshots` library |
| Errors | `{"error":"..."}` | `{code, message, retryable}` |

```mermaid
flowchart LR
  subgraph legacy [Legacy /sandbox*]
    C[POST /sandbox]
    E[POST /exec]
    D[DELETE /sandbox/id]
    P[POST /purge]
  end
  subgraph v1 [Public /v1]
    V1C[POST /v1/sandboxes]
    T[POST /tasks]
    S[POST /stop]
  end
  C --> Store[(SQLite)]
  V1C --> C
  T --> Runtimed[runtimed UDS]
```

## Related pages

<CardGroup>
<Card title="v1 API reference" href="/v1-api-reference">
Public `/v1/sandboxes` surface, tasks, files, and structured errors — preferred for new integrations.
</Card>
<Card title="API authentication" href="/api-authentication">
Service tokens, loopback exemption, SIGHUP reload, and preview-token paths.
</Card>
<Card title="Sandbox operations" href="/sandbox-operations">
Operational workflows: exec, keepalive, stop, delete vs purge, claim.
</Card>
<Card title="Observability" href="/observability">
Health semantics, Prometheus labels, and compose log probes.
</Card>
<Card title="Wake, idle, and pressure" href="/wake-idle-reapers">
Preview wake HTML, idle reaper, memory admission, and keepalive interaction.
</Card>
</CardGroup>
