# Manage sandboxes

> Operational workflows: create (ports, env, template), exec, keepalive, POST /v1/sandboxes/{id}/stop, DELETE vs POST purge, claim, and external-user purge hooks.

- 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/handlers.go`
- `control-plane/internal/api/v1.go`
- `control-plane/internal/api/external_purge.go`
- `AGENTS.md`
- `control-plane/internal/api/api.go`
- `control-plane/internal/store/store.go`

---

---
title: "Manage sandboxes"
description: "Operational workflows: create (ports, env, template), exec, keepalive, POST /v1/sandboxes/{id}/stop, DELETE vs POST purge, claim, and external-user purge hooks."
---

The `sandboxd` control plane exposes legacy `/sandbox*` routes and a narrower `/v1/sandboxes` layer over the same SQLite-backed lifecycle: create provisions a workspace and starts container `s-{ulid}`, exec and keepalive influence idle reaping, stop frees RAM while preserving disk, and destroy vs purge split on whether the workspace directory and `workspace_owner` row survive.

## Endpoint map

| Method | Path | Effect on container | Effect on workspace dir | Effect on DB row | Effect on `workspace_owner` |
| --- | --- | --- | --- | --- | --- |
| `POST` | `/sandbox` | Starts `s-{id}` | Creates or reuses under data dir | Inserts `sandbox` (+ owner on create) | Upsert on create |
| `POST` | `/sandbox/{id}/exec` | Runs command in running container | Unchanged | Bumps `last_active_at` | Unchanged |
| `POST` | `/sandbox/{id}/keepalive` | Unchanged | Unchanged | Sets `keepalive_until` | Unchanged |
| `POST` | `/v1/sandboxes/{id}/stop` | `docker stop` | Preserved | `status` → `stopped`, `stopped_at` | Unchanged |
| `DELETE` | `/sandbox/{id}` | `docker rm` | **Kept** on disk | Deletes `sandbox` row only | **Kept** |
| `POST` | `/sandbox/{id}/purge` | Stop + remove | **Deleted** (`RemoveAll`) | Deletes `sandbox` + owner | **Deleted** |
| `DELETE` | `/v1/sandboxes/{id}` | Same as purge | Same as purge | Same as purge | Same as purge |
| `POST` | `/sandbox/{id}/claim` | Unchanged | Unchanged | Updates external identity | Upsert owner |
| `POST` | `/external-users/{id}/purge` | Purge each owned sandbox | Delete each | Purge each | Delete each |
| `POST` | `/external-projects/{id}/purge` | Purge by project | Delete each | Purge each | Delete each |

<Note>
Loopback `Release` is a no-op in the OSS directory-storage build; workspace persistence is the bind-mounted directory under `SANDBOXED_DATA_DIR/workspaces/{id}/`, not a separate `.img` file.
</Note>

## Create a sandbox

`POST /sandbox` is the internal create path. `POST /v1/sandboxes` delegates to it after mapping `project.user_id` / `project.id` into `external` and fixing port `3000`.

### Request body (`POST /sandbox`)

<ParamField body="ports" type="int[]">
TCP ports exposed for Traefik preview routing. Each port must be 1–65535.
</ParamField>

<ParamField body="id" type="string">
Optional ULID. Omitted → auto-generated. Non-ULID values return `400` with `id must be a ULID`. Existing row → `409` (`DELETE` first for id-reuse with a new row).
</ParamField>

<ParamField body="env" type="object">
Key/value pairs passed to `docker run --env`. Keys must be non-empty and must not contain `=` or newlines; values must not contain newlines. Injected at create (e.g. `ANTHROPIC_API_KEY` for coding agents).
</ParamField>

<ParamField body="template" type="string">
Golden template name → `{SANDBOXD_TEMPLATES_DIR}/{name}.img` clone. Lowercase `[a-z0-9-]`, max 64 chars. Requires templates dir configured; unknown template → `400`.
</ParamField>

<ParamField body="template_path" type="string">
Internal only: pre-resolved absolute path under `LibraryRoot` or `TemplatesDir` (v1 `from_snapshot` spin-up). Mutually exclusive with `template`.
</ParamField>

<ParamField body="external" type="object">
`user_id` (defaults to `"local"` for OSS quickstart), optional `project_id`, `workspace_id`. IDs ≤256 chars, no control codes or commas.
</ParamField>

<ParamField body="visibility" type="string">
`public` (default) or `private` (forward-auth previews).
</ParamField>

<ParamField body="memory_high" type="string">
Soft cgroup throttle target; default `4G`. Applied only when `SANDBOXED_SET_MEMORY_HIGH=true`.
</ParamField>

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

### Create flow

```mermaid
sequenceDiagram
  participant Client
  participant sandboxd
  participant SQLite
  participant Loopback
  participant Docker

  Client->>sandboxd: POST /sandbox
  sandboxd->>sandboxd: admit check (memory floor)
  sandboxd->>SQLite: Create row (creating)
  sandboxd->>Loopback: Provision or ProvisionFromTemplate
  sandboxd->>Docker: run s-{id} + Traefik labels
  sandboxd->>SQLite: MarkRunning + BumpLastActive
  sandboxd-->>Client: 201 sandbox row
```

<Warning>
**Id-reuse guard:** If a `workspace_owner` row already exists for `id` (workspace on disk, no active `sandbox` row), `external.user_id` on create must match `workspace_owner.external_user_id` or the API returns `409` with `workspace_owner_mismatch`.
</Warning>

**Capacity:** Low host memory can return `503` with `Retry-After: 30` and `mem_available_percent` before any row is created.

**v1 idempotency:** `POST /v1/sandboxes` returns `200` with the existing sandbox when `external_project_id` already has a non-`error` row.

<RequestExample>

```bash
API=http://127.0.0.1:9090

curl -s -XPOST "$API/sandbox" \
  -H 'Content-Type: application/json' \
  -d '{
    "ports": [3000, 5173],
    "env": {"ANTHROPIC_API_KEY": "sk-ant-..."},
    "template": "react-standard",
    "external": {"user_id": "alice", "project_id": "proj-42"}
  }'
```

</RequestExample>

<ResponseExample>

```json
{
  "id": "01JXXXXXXXXXXXXXXXXXXXXXXX",
  "status": "running",
  "ports": [3000, 5173],
  "visibility": "public",
  "external_user_id": "alice",
  "external_project_id": "proj-42",
  "keepalive_until": 0,
  "last_active_at": 1717500000
}
```

</ResponseExample>

## Run commands (`POST /sandbox/{id}/exec`)

Non-interactive `docker exec` into `s-{id}`.

<ParamField body="cmd" type="string[]" required>
Argv passed to exec (e.g. `["bash","-lc","cd ~/workspace && npm test"]`).
</ParamField>

<ParamField body="stream" type="boolean">
When `true`, response is `200` chunked `text/plain` (stdout, optional `---stderr---`, trailing `exit_code: N`). Default JSON: `{stdout, stderr, exit_code}`.
</ParamField>

Exec registers in-flight activity, bumps `last_active_at` at start and end, and audit-logs only `cmd[0]` (not the full command line). Requires a running container; docker errors → `500`.

## Postpone idle stop (`POST /sandbox/{id}/keepalive`)

<ParamField body="until" type="int" required>
Unix timestamp (seconds). Must be in the future. Capped to `now + SANDBOXD_KEEPALIVE_MAX_SECONDS` (default **86400**, 24h).
</ParamField>

While `keepalive_until > now`, the idle reaper skips the sandbox. Response: `{id, keepalive_until}`.

Idle stop itself is automatic: `SANDBOXD_IDLE_THRESHOLD_SECONDS` (default **2100**, 35 min) after `last_active_at`, unless exec, keepalive, active task, or in-flight exec applies.

## Stop without deleting workspace

| Route | When to use |
| --- | --- |
| `POST /v1/sandboxes/{id}/stop` | Explicit stop; returns v1 sandbox object |
| Idle / pressure reapers | Automatic `docker stop` when thresholds hit |

`v1StopSandbox` behavior:

- Idempotent if already `stopped` → `200`
- `409 conflict` if status is not `running`
- `409 task_in_progress` if runtimed reports an active task (cancel task first)
- `docker stop` with 10s timeout, then `MarkStoppedAt`

Workspace files and `workspace_owner` remain. The next preview request wakes the sandbox (see wake routing).

<RequestExample>

```bash
curl -s -XPOST "$API/v1/sandboxes/$ID/stop"
```

</RequestExample>

## Destroy vs purge

```text
  DELETE /sandbox/{id}          POST /sandbox/{id}/purge
         |                                |
         v                                v
   docker rm s-{id}              purgeOne():
   (workspace dir KEPT)            stop + rm container
   DELETE sandbox row              RemoveAll workspace dir
   workspace_owner SURVIVES       Remove _snapshots/{id}/
                                   PurgeSandbox (row + owner)
```

### `DELETE /sandbox/{id}` — soft destroy

- Holds per-id lock for the operation
- Removes container, no-ops `Loopback.Release`, deletes **only** the `sandbox` table row
- Response: **`204 No Content`**
- **Id-reuse:** `POST /sandbox` with the same `id` reattaches the existing workspace if `workspace_owner` matches

Use before manual snapshot/restore when the sandbox must not be `running` (`409` if running).

### `POST /sandbox/{id}/purge` — hard teardown

`purgeOne` performs, in order:

1. Resolve `workspace_owner.external_user_id` for audit (before row delete)
2. Stop and remove `s-{id}` if present
3. Egress rule cleanup
4. `Loopback.Release` (no-op in OSS)
5. `os.RemoveAll` workspace directory
6. `os.RemoveAll` `{SnapshotsRoot}/{id}/` when configured
7. `Store.PurgeSandbox` — deletes `sandbox` and `workspace_owner`

<ResponseExample>

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

</ResponseExample>

Audit action: `sandbox.purge` with `freed_bytes` in detail.

### `DELETE /v1/sandboxes/{id}`

Delegates to `handlePurgeSandbox`, not soft `DELETE`. Integrators destroying a project sandbox should use this route; it returns **`204`** on success.

## Claim upstream identity (`POST /sandbox/{id}/claim`)

Reassigns a legacy or backfilled sandbox to a real upstream tenant. Updates `sandbox.external_*` and upserts `workspace_owner` in one transaction.

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

<ParamField body="external_project_id" type="string">
Optional; empty leaves project unchanged on existing owner row.
</ParamField>

<ParamField body="external_workspace_id" type="string">
Optional; empty leaves workspace unchanged on existing owner row.
</ParamField>

<ResponseExample>

```json
{
  "id": "01JXXXXXXXXXXXXXXXXXXXXXXX",
  "external_user_id": "tenant-user-9",
  "claimed": true
}
```

</ResponseExample>

Audit: `sandbox.claim` with `prior_external_user_id` / `new_external_user_id` in detail. Requires a `sandbox` row (`404` if missing).

<Tip>
Enable `SANDBOXD_API_AUTH_DISABLED=false` and bearer tokens for claim and purge on any non-loopback API exposure; local default bind `127.0.0.1:9090` bypasses the middleware as operator loopback.
</Tip>

## External-user and external-project purge

Bulk teardown for tenant offboarding. Both routes call `purgeScope`, which looks up sandbox IDs from `workspace_owner` and runs `purgeOne` per id.

:::endpoint POST /external-users/{external_user_id}/purge
Purges every sandbox whose `workspace_owner.external_user_id` matches. Per-sandbox `sandbox.purge` audit rows plus summary `external_user.purge`.
:::

:::endpoint POST /external-projects/{external_project_id}/purge
Purges every sandbox whose `workspace_owner.external_project_id` matches. Summary action `external_project.purge`.
:::

<ResponseExample>

```json
{
  "purged_count": 3,
  "freed_bytes": 314572800
}
```

</ResponseExample>

<Warning>
On the first per-sandbox failure, the handler returns `500` and stops (partial purge may have already completed). Retry to finish remaining sandboxes.
</Warning>

## List and inspect

| Route | Notes |
| --- | --- |
| `GET /sandboxes` | All rows, newest first |
| `GET /sandboxes?external_user_id=&external_project_id=` | Filtered list |
| `GET /sandbox/{id}` | Row + optional `live_state` (docker inspect) + `runtime` (runtimed status) |

## Operational checklist

<Steps>
<Step title="Create with ports and env">
`POST /sandbox` or `POST /v1/sandboxes`; record `id` from response.
</Step>
<Step title="Drive work">
Tasks API, `exec`, or preview traffic (bumps activity).
</Step>
<Step title="Extend idle window if needed">
`POST /sandbox/{id}/keepalive` with future `until`.
</Step>
<Step title="Stop to free RAM">
`POST /v1/sandboxes/{id}/stop` or wait for idle reaper.
</Step>
<Step title="Tear down">
Soft: `DELETE /sandbox/{id}` (keep files for reuse). Hard: `POST /sandbox/{id}/purge` or `DELETE /v1/sandboxes/{id}`.
</Step>
<Step title="Tenant purge">
`POST /external-users/{id}/purge` or `POST /external-projects/{id}/purge`.
</Step>
</Steps>

## v1 vs legacy quick reference

| Goal | Legacy | v1 |
| --- | --- | --- |
| Create | `POST /sandbox` | `POST /v1/sandboxes` |
| Get | `GET /sandbox/{id}` | `GET /v1/sandboxes/{id}` |
| Stop | — (use reaper or implement via docker) | `POST /v1/sandboxes/{id}/stop` |
| Destroy, keep workspace | `DELETE /sandbox/{id}` | — |
| Destroy + delete workspace | `POST /sandbox/{id}/purge` | `DELETE /v1/sandboxes/{id}` |

v1 errors use `{error: {code, message, retryable}}`; legacy routes use `{error: "message"}`.

## Related pages

<CardGroup>
<Card title="Sandbox lifecycle" href="/sandbox-lifecycle">
Status machine, reconcile-on-boot, and destroy vs purge semantics in depth.
</Card>
<Card title="Wake, idle, and pressure" href="/wake-idle-reapers">
Idle threshold, keepalive skip rules, wake-on-preview, and memory admission.
</Card>
<Card title="Control plane API (legacy)" href="/legacy-api-reference">
Full `/sandbox*` route inventory including snapshots and wake JSON.
</Card>
<Card title="v1 API reference" href="/v1-api-reference">
Public shapes, error envelope, tasks, and files CRUD.
</Card>
<Card title="API authentication" href="/api-authentication">
Bearer tokens, loopback exemption, and privileged-route exposure.
</Card>
<Card title="Workspaces and isolation" href="/workspaces-persistence">
Bind-mount layout, seeding, templates, and directory storage trade-offs.
</Card>
</CardGroup>
