# v1 API reference

> Public /v1/sandboxes and /v1/snapshots: request/response shapes, error envelope (code, message, retryable), files CRUD, export zip, task lifecycle states, and template spin-up.

- 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/v1.go`
- `control-plane/internal/api/v1_tasks.go`
- `control-plane/internal/api/v1_files.go`
- `control-plane/internal/api/v1_files_write.go`
- `control-plane/internal/api/v1_snapshots.go`
- `control-plane/migrations/0005_tasks.sql`
- `control-plane/migrations/0009_snapshots.sql`

---

---
title: "v1 API reference"
description: "Public /v1/sandboxes and /v1/snapshots: request/response shapes, error envelope (code, message, retryable), files CRUD, export zip, task lifecycle states, and template spin-up."
---

The public `/v1` surface in `control-plane/internal/api/v1*.go` is a translation layer over the internal `POST /sandbox` machinery and the in-sandbox `runtimed` Unix socket. Integrators call `sandboxd` at `SANDBOXED_API_BIND` (default `http://127.0.0.1:9090`). When API auth is enabled, every `/v1/*` route expects `Authorization: Bearer <token>` except documented exemptions on loopback.

## Error envelope

Failed responses use a single JSON shape. HTTP status codes carry semantics; the `error.code` string is stable for programmatic handling.

```json
{
  "error": {
    "code": "not_found",
    "message": "no such sandbox",
    "retryable": false
  }
}
```

<ResponseField name="error.code" type="string">
Machine-readable error identifier. Common values: `invalid_request`, `not_found`, `conflict`, `sandbox_capacity`, `internal`, plus explicit codes `task_in_progress` and `sandbox_unavailable`.
</ResponseField>

<ResponseField name="error.message" type="string">
Human-readable detail, often forwarded from the internal handler or runtimed.
</ResponseField>

<ResponseField name="error.retryable" type="boolean">
`true` only for HTTP `502` and `503`. On `503` capacity refusal, the server also sets `Retry-After: 30`.
</ResponseField>

| HTTP | Typical `error.code` | When |
|------|----------------------|------|
| 400 | `invalid_request` | Bad JSON, validation, path traversal, file size caps |
| 404 | `not_found` | Missing sandbox, task, snapshot, or directory (cross-tenant snapshots return 404) |
| 409 | `conflict` or `task_in_progress` | Wrong sandbox status, active task, running snapshot source |
| 413 | `invalid_request` | PUT body over 25 MiB |
| 502 | `sandbox_unavailable` | Cannot reach runtimed (socket absent, task stream/cancel failures) |
| 503 | `sandbox_capacity` | Memory admission refused on create/wake |
| 500 | `internal` | Store, Docker, or capture failures |

<Note>
Internal `/sandbox*` errors use a flat `{"error":"..."}` string. The v1 layer reshapes those bodies via `relayV1Error`; callers should only parse the v1 envelope on `/v1/*`.
</Note>

## Endpoint inventory

| Method | Path | Purpose |
|--------|------|---------|
| POST | `/v1/sandboxes` | Create (or return existing) project sandbox |
| GET | `/v1/sandboxes/{id}` | Get sandbox + live preview snapshot |
| POST | `/v1/sandboxes/{id}/stop` | Stop container (idle); idempotent if already stopped |
| DELETE | `/v1/sandboxes/{id}` | Full destroy (purge: container + workspace + row) |
| POST | `/v1/sandboxes/{id}/tasks` | Submit coding task (wake-on-submit) |
| GET | `/v1/sandboxes/{id}/tasks/{taskId}` | Canonical task result (SQLite-backed) |
| GET | `/v1/sandboxes/{id}/tasks/{taskId}/events` | SSE progress stream |
| POST | `/v1/sandboxes/{id}/tasks/{taskId}/cancel` | Cancel in-flight task |
| GET | `/v1/sandboxes/{id}/files` | List files under `workspace/app` |
| GET | `/v1/sandboxes/{id}/files/content` | Read one file (2 MiB cap) |
| PUT | `/v1/sandboxes/{id}/files` | Write opaque bytes into workspace mount |
| GET | `/v1/sandboxes/{id}/export` | Zip download of `workspace/app` |
| POST | `/v1/snapshots` | Capture stopped sandbox workspace image |
| GET | `/v1/snapshots` | List tenant snapshots |
| GET | `/v1/snapshots/{id}` | Get one snapshot |
| DELETE | `/v1/snapshots/{id}` | Delete snapshot image + row |

There is no `GET /v1/sandboxes` list route; discovery is by project idempotency on create or by retained sandbox id.

## Sandbox resource

The v1 sandbox object folds SQLite row state with a live preview probe from `runtimed` when the Unix socket answers (3s timeout).

<ResponseField name="id" type="string">ULID sandbox identifier; container name is `s-{id}`.</ResponseField>
<ResponseField name="status" type="string">Control-plane status: `creating`, `running`, `stopped`, or `error`.</ResponseField>
<ResponseField name="preview" type="object">Dev-server snapshot: `url`, `status` (`down` | `starting` | `ready` | `error`), optional `last_http_status`, `last_checked_at`, `build_error_message`.</ResponseField>
<ResponseField name="active_task_id" type="string">Set when runtimed reports an active task.</ResponseField>
<ResponseField name="template" type="string">Always `react-standard` in responses today (fixed v1 default label).</ResponseField>
<ResponseField name="git_remote_url" type="string">Assigned HTTPS push target for auto-git-push after tasks, if configured at create.</ResponseField>
<ResponseField name="created_at" type="string">RFC3339 UTC timestamp.</ResponseField>
<ResponseField name="updated_at" type="string">RFC3339 UTC timestamp.</ResponseField>

Preview URLs are synthesized as `https://s-{id}-3000.preview.{PREVIEW_DOMAIN}` (port 3000 is fixed on the v1 create path).

:::endpoint POST /v1/sandboxes
Create a durable sandbox for a project, or return the existing non-`error` sandbox for the same `project.id` (HTTP 200, idempotent).

<ParamField body="project.id" type="string" required>
External project identifier; drives idempotent reuse.
</ParamField>

<ParamField body="project.user_id" type="string" required>
External user identifier stored on the sandbox row.
</ParamField>

<ParamField body="visibility" type="string">
`public` (default) or `private`. Passed through to internal create.
</ParamField>

<ParamField body="template" type="string">
Golden template name (e.g. `react-standard`). Resolved to `{SANDBOXD_TEMPLATES_DIR}/{name}.img`. Default when omitted: `react-standard`. Mutually exclusive with `from_snapshot`.
</ParamField>

<ParamField body="from_snapshot" type="string">
Snapshot ULID owned by the API tenant. Source image must be `ready`. Workspace is cloned from the snapshot `.img` via internal `template_path`. Mutually exclusive with `template`.
</ParamField>

<ParamField body="git_remote_url" type="string">
HTTPS remote for post-task workspace push (not a secret; host holds credentials).
</ParamField>

<RequestExample>
```bash
curl -s -X POST http://127.0.0.1:9090/v1/sandboxes \
  -H 'Content-Type: application/json' \
  -d '{
    "project": {"id": "proj_01", "user_id": "user_01"},
    "template": "react-standard",
    "visibility": "public"
  }'
```
</RequestExample>

<ResponseExample>
```json
{
  "id": "01JABCDEFGHJKMNPQRSTVWXYZ0",
  "status": "running",
  "preview": {
    "url": "https://s-01JABCDEFGHJKMNPQRSTVWXYZ0-3000.preview.localhost",
    "status": "starting"
  },
  "template": "react-standard",
  "created_at": "2026-06-04T12:00:00Z",
  "updated_at": "2026-06-04T12:00:05Z"
}
```
</ResponseExample>

Returns `201 Created` on first create, `200 OK` when reusing an existing project sandbox. Delegates to `POST /sandbox` with `ports: [3000]` and `external` tags. Capacity refusal surfaces as `503` / `sandbox_capacity` with `Retry-After: 30`.
:::

:::endpoint GET /v1/sandboxes/{id}
Fetch one sandbox. Works regardless of container run state; preview fields reflect runtimed when reachable, else `preview.status` is `down` or `starting` (running sandbox, socket not up yet).
:::

:::endpoint POST /v1/sandboxes/{id}/stop
Stop the Docker container and mark the sandbox `stopped`. Idempotent when already stopped.

<Warning>
Returns `409` / `task_in_progress` if runtimed reports an active task. Cancel the task first.
</Warning>

Non-`running` sandboxes (except already `stopped`) return `409` / `conflict`.
:::

:::endpoint DELETE /v1/sandboxes/{id}
Full destroy: delegates to internal `POST /sandbox/{id}/purge` (container, workspace image, SQLite row). Success is `204 No Content`. This is not the soft internal `DELETE` that preserves workspace images for id reuse.
:::

## Template spin-up

v1 create always exposes port **3000** and seeds the workspace in one of three ways:

```text
POST /v1/sandboxes
        │
        ├─ from_snapshot ──► authorize tenant snapshot ──► template_path = library/{id}.img
        │
        ├─ template name ──► {SANDBOXD_TEMPLATES_DIR}/{name}.img  (default: react-standard)
        │
        └─ (neither) ───────► internal empty provision + skeleton seed
```

<ParamField body="template" type="string">
Name must match `^[a-z0-9-]{1,64}$`. Host must have `SANDBOXD_TEMPLATES_DIR` set; unknown names fail before row creation.
</ParamField>

<ParamField body="from_snapshot" type="string">
Uses `ProvisionFromTemplate` with a raw `.img` under `LibraryRoot`. Snapshot must belong to the authenticated API token's tenant (`owner_token` = token name). Cross-tenant IDs return `404`.
</ParamField>

<Tip>
Fast cold start: prebuilt golden `.img` templates skip scaffold/install on the create hot path. Snapshots are independent ext4 copies—deleting a snapshot never affects sandboxes already cloned from it.
</Tip>

## Tasks

Task submission wakes a **stopped** sandbox first (internal wake JSON), then calls `runtimed` `POST /tasks`. Only agent **`opencode`** is accepted on v1 (default when `agent` omitted). One active task per sandbox.

:::endpoint POST /v1/sandboxes/{id}/tasks
<ParamField body="prompt" type="string" required>Coding instruction for the agent.</ParamField>
<ParamField body="agent" type="string">Must be `opencode` if set.</ParamField>

<ResponseExample>
```json
{
  "id": "01JTASKULIDEXAMPLE000000001",
  "sandbox_id": "01JABCDEFGHJKMNPQRSTVWXYZ0",
  "status": "running",
  "agent": "opencode",
  "events_url": "/v1/sandboxes/01JABCDEFGHJKMNPQRSTVWXYZ0/tasks/01JTASKULIDEXAMPLE000000001/events"
}
```
</ResponseExample>

HTTP **202 Accepted**. A background watcher persists the terminal `done` event into SQLite (`task` table, migration `0005_tasks.sql`) so results survive stop and destroy.
:::

:::endpoint GET /v1/sandboxes/{id}/tasks/{taskId}
While `status` is `running` or `result_json` is unset, returns a minimal `{"id","sandbox_id","status":"running"}`. When finished, returns the full `runtime.TaskResult` plus `sandbox_id` (promoted JSON fields).
:::

:::endpoint GET /v1/sandboxes/{id}/tasks/{taskId}/events
Server-Sent Events (`text/event-stream`). Resume with `Last-Event-ID` (next event is `id+1`) or query `?since=N`. Each event:

```text
id: 42
event: message
data: {"..."}

```

Event types from runtimed: `status`, `message`, `tool` (best-effort), `build`, and terminal `done` (carries `TaskResult` JSON). Stream ends after `done`.
:::

:::endpoint POST /v1/sandboxes/{id}/tasks/{taskId}/cancel
Proxies to runtimed cancel. Response: `{"id":"<taskId>","status":"cancelling"}`.
:::

### Task lifecycle states

SQLite `task.status` and `TaskResult.status` use the same vocabulary:

| Status | Meaning |
|--------|---------|
| `running` | Accepted; watcher or runtimed still in progress |
| `succeeded` | Terminal success (`done` event persisted) |
| `failed` | Terminal failure (agent, build, watcher, or reconcile) |
| `cancelled` | User-cancelled terminal state |

```mermaid
stateDiagram-v2
    [*] --> running: POST /tasks (202)
    running --> succeeded: done event (build/preview OK)
    running --> failed: done or watcher failure
    running --> cancelling: POST .../cancel
    cancelling --> cancelled: runtimed terminal
    cancelling --> failed: abnormal end
    succeeded --> [*]
    failed --> [*]
    cancelled --> [*]
```

`TaskResult` fields (canonical outcome—no event replay required):

<ResponseField name="status" type="string">Terminal status.</ResponseField>
<ResponseField name="failure_reason" type="string">e.g. `sandbox_unavailable`, `internal` on watcher failures.</ResponseField>
<ResponseField name="files_changed" type="string[]">Paths touched by the agent.</ResponseField>
<ResponseField name="build_ok" type="boolean">`pnpm build` outcome.</ResponseField>
<ResponseField name="build_error_message" type="string">Build stderr summary when `build_ok` is false.</ResponseField>
<ResponseField name="preview_status_after" type="string">`down` | `starting` | `ready` | `error` after post-task health.</ResponseField>
<ResponseField name="preview_error_message" type="string">Live dev-server error when build passed but preview fails.</ResponseField>
<ResponseField name="tokens" type="object">`input`, `output`, `reasoning`, cache fields, `total`, `cost`.</ResponseField>
<ResponseField name="duration_ms" type="number">Wall time.</ResponseField>

<Info>
`GET /v1/.../tasks/{id}` reads SQLite only—safe after `DELETE /v1/sandboxes/{id}`. The full event log lives under `.runtimed/` in the workspace and is **not** retained past destroy.
</Info>

On `sandboxd` restart, `ReconcileTasks` finalizes stuck `running` rows from runtimed `result.json`, re-attaches watchers, or marks `failed` / `sandbox_unavailable`.

## Files and export

File **reads** and **export** root at `workspace/app` on the host loopback mount. They work whether or not the container is running.

| Operation | Root | Limits / exclusions |
|-----------|------|---------------------|
| `GET .../files` | `workspace/app` | Query `path` (default `""`), `recursive=true` optional |
| `GET .../files/content` | `workspace/app` | Query `path`; 2 MiB max per file; `text/plain` body |
| `GET .../export` | `workspace/app` | `application/zip`; attachment `{id}.zip` |
| `PUT .../files` | **Workspace mount root** | Query `path` relative to mount; 25 MiB max |

Excluded from list/read/export: `node_modules`, `.git`, `dist`, `.vite`.

:::endpoint GET /v1/sandboxes/{id}/files
<ResponseExample>
```json
{
  "path": "src",
  "recursive": false,
  "entries": [
    {"path": "src/App.tsx", "type": "file", "size": 412},
    {"path": "src/components", "type": "dir"}
  ]
}
```
</ResponseExample>
:::

:::endpoint PUT /v1/sandboxes/{id}/files?path={rel}
Raw request body bytes (opaque). Atomic write: temp file in-target directory, `O_NOFOLLOW`, rename. Refuses `.runtimed/`, `lost+found/`, `..` segments, directory paths, symlinks at leaf. `chown` to workspace owner when known.

<ResponseExample>
```json
{"path": "AGENTS.md", "size": 1024}
```
</ResponseExample>

Use this to inject `AGENTS.md`, `CLAUDE.md`, `opencode.json`, or other agent config at the workspace root—not only under `app/`.
:::

:::endpoint GET /v1/sandboxes/{id}/export
Streams a zip of all files under `workspace/app` (same exclusions). No JSON wrapper.
:::

## Snapshots

Snapshots freeze a **stopped** sandbox workspace `.img` into `LibraryRoot/{id}.img`. Ownership is the API token name (`auth.Actor.Name`), not `external user_id`.

<ResponseField name="id" type="string">ULID.</ResponseField>
<ResponseField name="name" type="string">Caller-chosen label.</ResponseField>
<ResponseField name="status" type="string">`ready` or `error` (v1 create path sets `ready` synchronously).</ResponseField>
<ResponseField name="source_sandbox_id" type="string">Provenance.</ResponseField>
<ResponseField name="base_image" type="string">Captured sandbox image name (recorded, not pinned on spin-up).</ResponseField>
<ResponseField name="visibility" type="string">`private` in v1.</ResponseField>
<ResponseField name="size_bytes" type="number">Allocated on-disk bytes (sparse-aware).</ResponseField>

:::endpoint POST /v1/snapshots
<ParamField body="source_sandbox_id" type="string" required>Must exist; must **not** be `running`.</ParamField>
<ParamField body="name" type="string" required>Display name for the library entry.</ParamField>

Capture holds the source sandbox id-lock, copies with `cp --reflink=auto --sparse=always`, then inserts the row. Returns `201` with the snapshot object. Requires `LibraryRoot` configured; otherwise `503` / `internal`.
:::

:::endpoint GET /v1/snapshots
Returns `{"snapshots":[...]}` for the current tenant token only.
:::

:::endpoint GET /v1/snapshots/{id}
Tenant-scoped get; cross-tenant id returns `404`.
:::

:::endpoint DELETE /v1/snapshots/{id}
Removes image file and row; `204` on success. Cloned sandboxes are unaffected.
:::

Wire snapshot into new sandboxes with `from_snapshot` on `POST /v1/sandboxes`.

## Architecture seam

```mermaid
sequenceDiagram
    participant Client
    participant sandboxd as sandboxd /v1
    participant Internal as POST /sandbox
    participant RT as runtimed (Unix socket)
    participant SQL as SQLite task/snapshot

    Client->>sandboxd: POST /v1/sandboxes
    sandboxd->>Internal: delegate create
    Internal-->>sandboxd: 201 + id
    sandboxd-->>Client: v1 Sandbox

    Client->>sandboxd: POST /v1/.../tasks
    alt sandbox stopped
        sandboxd->>Internal: wake
    end
    sandboxd->>RT: StartTask
    sandboxd->>SQL: CreateTask running
    sandboxd-->>Client: 202 + events_url
    loop watchTask goroutine
        RT-->>sandboxd: SSE/NDJSON done
        sandboxd->>SQL: FinishTask + result_json
    end
```

<Info>
The machine-readable integrator contract is also published at `GET /llm.txt` when `SANDBOXD_LLM_TXT_PATH` is configured on the host.
</Info>

## Related pages

<CardGroup>
<Card title="API authentication" href="/api-authentication">
Bearer tokens, auth disable rollback, and loopback exemptions for `/v1/*`.
</Card>
<Card title="Run coding agents" href="/run-coding-agents">
Wake-on-submit, SSE streaming, and env injection at create.
</Card>
<Card title="Sandbox lifecycle" href="/sandbox-lifecycle">
SQLite status machine and destroy vs purge semantics behind v1 delete.
</Card>
<Card title="Control plane API (legacy)" href="/legacy-api-reference">
Internal `/sandbox*` routes this layer delegates to.
</Card>
<Card title="runtimed reference" href="/runtimed-reference">
In-sandbox supervisor protocol under `.runtimed/sock`.
</Card>
<Card title="Quickstart" href="/quickstart">
Shortest create → task → preview curl flow.
</Card>
</CardGroup>
