# Overview

> What sandboxed exposes (sandboxd API, Traefik previews, runtimed tasks), runtime assumptions (Docker, Linux, SQLite), and the shortest create → task → preview path.

- 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

- `README.md`
- `ARCHITECTURE.md`
- `docker-compose.yml`
- `AGENTS.md`
- `control-plane/cmd/sandboxd/main.go`
- `control-plane/internal/api/api.go`

---

---
title: "Overview"
description: "What sandboxed exposes (sandboxd API, Traefik previews, runtimed tasks), runtime assumptions (Docker, Linux, SQLite), and the shortest create → task → preview path."
---

sandboxed is a single-host stack: **sandboxd** (Go control plane) shells out to the Docker daemon, **Traefik** routes preview hostnames to sandbox containers, and each **sandbox** is a sibling container built from `sandboxed-base:1.0.0` with **runtimed** as its main process. SQLite at `${SANDBOXED_DATA_DIR}/state/sandboxd.db` is the source of truth; workspaces persist under `${SANDBOXED_DATA_DIR}/workspaces/<id>/`. The default API bind is `127.0.0.1:9090` (container listens on `:9000`).

## What you integrate against

Three surfaces matter for product backends and agents:

| Surface | Default reachability | Role |
|---|---|---|
| **sandboxd HTTP API** | `http://127.0.0.1:9090` (`SANDBOXED_API_BIND`) | Create sandboxes, exec, files, stop/destroy, tasks (v1), health |
| **Traefik preview URLs** | `http://s-<id>-<port>.preview.<domain>[:HTTP_PORT]` | Browser traffic to dev servers inside sandboxes; wake when stopped |
| **runtimed (in-sandbox)** | Unix socket at `workspaces/<id>/.runtimed/sock` | Task supervisor; reached by sandboxd via `internal/runtime.Client`, not directly from the host API |

Legacy routes (`POST /sandbox`, `GET /sandboxes`, …) and the public **v1** layer (`POST /v1/sandboxes`, `POST /v1/sandboxes/{id}/tasks`, …) share one listener. Service-token auth wraps the API mux only; the Traefik wake catch-all stays unauthenticated (private sandboxes gate inside the wake handler).

<Info>
Auth is open by default (`SANDBOXD_API_AUTH_DISABLED=true`). Loopback callers still work when tokens are required; enable tokens before binding the API on a LAN.
</Info>

## Runtime assumptions

| Requirement | Detail |
|---|---|
| **Host OS** | Linux (install and compose target a Linux Docker host) |
| **Container runtime** | Docker Engine + Compose plugin (`docker compose`) |
| **Network** | Shared bridge `${SANDBOXED_NETWORK:-sandboxed_net}`; sandboxes join it so Traefik can route |
| **State** | SQLite WAL at `state/sandboxd.db`; boot **reconciler** converges Docker to DB rows |
| **Data dir** | Absolute `SANDBOXED_DATA_DIR` (default `/var/lib/sandboxed`), bind-mounted host:container symmetrically |
| **Preview DNS** | `PREVIEW_DOMAIN=localhost` works locally (`*.localhost` → 127.0.0.1); production uses a wildcard domain + optional TLS |

Infra containers (`traefik`, `sandboxd`) use `userns_mode: host` so workspace ownership stays deterministic when the daemon uses `userns-remap`. Sandboxes default to `SANDBOXED_USERNS=host` for the same reason.

## Stack layout

```mermaid
flowchart TB
  subgraph host["Host — Docker daemon"]
    subgraph edge["Edge"]
      Traefik["traefik:v3\nDocker + file providers"]
    end
    subgraph cp["Control plane"]
      sandboxd["sandboxd\n:9000 internal\npublished as SANDBOXED_API_BIND"]
      SQLite[("sandboxd.db\nSQLite WAL")]
    end
    subgraph sb["Per-sandbox (runtime)"]
      Container["s-{ulid}\nsandboxed-base:1.0.0"]
      runtimed["runtimed\n.runtimed/sock"]
      WS["workspace bind mount\n/home/sandbox"]
    end
    Data["SANDBOXED_DATA_DIR\nworkspaces/ + state/"]
  end
  Browser --> Traefik
  API["API / CLI"] --> sandboxd
  Traefik -->|"priority 100 Host rule"| Container
  Traefik -->|"priority 1 catch-all → sandboxd"| sandboxd
  sandboxd --> SQLite
  sandboxd -->|"docker CLI"| Container
  sandboxd -->|"runtime.Client unix"| runtimed
  Container --> WS
  Data --- WS
  Data --- SQLite
```

**sandboxd** owns lifecycle (create, exec, stop, destroy, purge), workspace seeding from `/opt/sandbox-skel`, Traefik label emission, idle and memory **reapers**, and the **wake** path. **runtimed** supervises dev servers and runs coding tasks (OpenCode is the supported v1 agent). **Traefik** scopes routing to containers labeled `sandboxed.managed=true`.

## Sandbox status model

Rows in the `sandbox` table use:

| Status | Meaning |
|---|---|
| `creating` | Provision in flight; `container_id` may be NULL |
| `running` | Container up; preview routers at Traefik priority 100 |
| `stopped` | `docker stop` succeeded; workspace retained; wake on next preview or task |
| `error` | Last failure recorded in `error_message`; needs operator attention |

Tasks have their own lifecycle (`running` → `succeeded` | `failed` | `cancelled`) in SQLite, independent of sandbox stop.

## Shortest path: create → task → preview

<Steps>
  <Step title="Install the stack">
    On a Linux host with Docker: clone the repo, run `./install.sh`, then verify:

    ```bash
    curl -s http://127.0.0.1:9090/healthz   # ok
    curl -s http://127.0.0.1:9090/readyz    # ready
    ```
  </Step>
  <Step title="Create a sandbox with an exposed port">
    <RequestExample>
    ```bash
    curl -s -XPOST http://127.0.0.1:9090/sandbox \
      -H 'content-type: application/json' \
      -d '{"ports":[3000]}'
    ```
    </RequestExample>

    Omit `id` to auto-generate a ULID. Optional `env` injects variables (e.g. provider API keys) into the container for agents and shells.
  </Step>
  <Step title="Submit a coding task">
    <RequestExample>
    ```bash
    curl -s -XPOST http://127.0.0.1:9090/v1/sandboxes/$ID/tasks \
      -H 'content-type: application/json' \
      -d '{"prompt":"create a Vite todo app and run it on port 3000","agent":"opencode"}'
    ```
    </RequestExample>

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

    If the sandbox is `stopped`, sandboxd **wake-on-task-submit** runs the internal wake path before calling runtimed. Default agent is `opencode` when omitted.
  </Step>
  <Step title="Stream task progress (optional)">
    ```bash
    curl -N http://127.0.0.1:9090/v1/sandboxes/$ID/tasks/$TASK_ID/events
    ```

    Server-Sent Events; the API mux flushes streaming responses for live output.
  </Step>
  <Step title="Open the preview URL">
    ```
    http://s-<id>-3000.preview.localhost
    ```

    Add `:${HTTP_PORT}` when `HTTP_PORT` is not `80`. First hit to a stopped sandbox may show the warming page until Traefik switches from the catch-all (priority 1) to the container router (priority 100).
  </Step>
</Steps>

```mermaid
sequenceDiagram
  participant Client as API client
  participant SD as sandboxd
  participant DB as SQLite
  participant RT as runtimed
  participant TR as Traefik
  participant BR as Browser

  Client->>SD: POST /sandbox {"ports":[3000]}
  SD->>DB: insert row (creating → running)
  SD-->>Client: id, status

  Client->>SD: POST /v1/sandboxes/{id}/tasks
  alt sandbox stopped
    SD->>SD: wake (docker start)
  end
  SD->>RT: POST /tasks (unix socket)
  SD->>DB: task row running
  SD-->>Client: task id, events_url

  Client->>SD: GET .../events (SSE)
  SD->>RT: stream events

  BR->>TR: GET s-{id}-3000.preview.localhost
  TR->>SD: catch-all if stopped
  SD->>SD: docker start, warming page
  TR->>RT: proxy to dev server :3000
```

<Tip>
You can skip the tasks API and use `POST /sandbox/{id}/exec` to start a dev server, then open the same preview hostname. See the exec-based example page.
</Tip>

## Control-plane API map (high level)

| Method & path | Purpose |
|---|---|
| `POST /sandbox` or `POST /v1/sandboxes` | Create sandbox (`ports`, optional `env`, optional `id`) |
| `POST /v1/sandboxes/{id}/tasks` | Run coding agent via runtimed |
| `GET /v1/sandboxes/{id}/tasks/{taskId}/events` | SSE task stream |
| `POST /sandbox/{id}/exec` | Non-interactive command in container |
| `POST /v1/sandboxes/{id}/stop` | Stop now (free RAM); wake on next preview |
| `DELETE /sandbox/{id}` | Destroy container, keep workspace |
| `POST /sandbox/{id}/purge` | Destroy container and delete workspace |
| `GET /healthz`, `GET /readyz` | Liveness / readiness (ready checks Docker reachability) |

v1 responses use a structured error envelope (`code`, `message`, `retryable`) for integrators; legacy routes remain for internal and operator tooling.

## Preview routing in one glance

- **Hostname pattern:** `s-{ulid}-{port}.preview.{PREVIEW_DOMAIN}`
- **Running sandbox:** Docker labels from sandboxd register a Traefik router at **priority 100** with `Host(...)` matching that name.
- **Stopped sandbox:** No priority-100 router → file-provider catch-all (`traefik/dynamic/wake.yml`, **priority 1**) forwards to `http://sandboxd:9000`, which starts the container and serves a warming page until the app port is ready.

## Persistence and isolation (summary)

| Class | Location | Survives stop? |
|---|---|---|
| Workspace files | `workspaces/<id>/` bind-mounted at `/home/sandbox` | Yes |
| Control-plane state | `state/sandboxd.db` | Yes |
| Container writable layer | None (`--read-only` rootfs, tmpfs for `/tmp`) | No |

Sandboxes run with hardened defaults: cap-drop ALL, `no-new-privileges`, read-only rootfs, memory and PID limits. The v1 threat model targets **authenticated, accountable users** on a dedicated host—not anonymous multi-tenant hostile code without stronger isolation (VM, gVisor, etc.).

## Default configuration knobs

| Variable | Default | Effect |
|---|---|---|
| `PREVIEW_DOMAIN` | `localhost` | Preview hostname suffix |
| `HTTP_PORT` | `80` | Host port Traefik publishes |
| `SANDBOXED_API_BIND` | `127.0.0.1:9090` | Published control-plane API |
| `SANDBOXED_DATA_DIR` | `/var/lib/sandboxed` | Workspaces + SQLite |
| `SANDBOXD_IDLE_THRESHOLD_SECONDS` | `2100` | Idle stop threshold (~35 min) |
| `SANDBOXD_API_AUTH_DISABLED` | `true` | API tokens optional locally |

## Related pages

<CardGroup>
  <Card title="Installation" href="/installation">
    Prerequisites, install.sh, compose up, and healthz/readyz checks.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    Copy-paste create, task, SSE, and preview flow.
  </Card>
  <Card title="Run coding agents" href="/run-coding-agents">
    Tasks API, wake-on-submit, env injection, and runtimed contract.
  </Card>
  <Card title="Preview routing" href="/preview-routing">
    Traefik priorities, Host rules, and wake catch-all.
  </Card>
  <Card title="Sandbox lifecycle" href="/sandbox-lifecycle">
    Status machine, reconcile-on-boot, destroy vs purge.
  </Card>
</CardGroup>
