# Exec a dev server preview

> Recipe without tasks API: POST /sandbox/{id}/exec to start a server on an exposed port, wake stopped sandboxes via preview hit, and curl with Host header locally.

- 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`
- `AGENTS.md`
- `control-plane/internal/api/handlers.go`
- `control-plane/internal/wake/handler.go`
- `traefik/dynamic/wake.yml`

---

---
title: "Exec a dev server preview"
description: "Recipe without tasks API: POST /sandbox/{id}/exec to start a server on an exposed port, wake stopped sandboxes via preview hit, and curl with Host header locally."
---

`POST /sandbox/{id}/exec` runs a non-interactive command inside container `s-{ulid}` via `docker exec`; Traefik routes `s-{id}-{port}.preview.{PREVIEW_DOMAIN}` to that port when the sandbox is running, and the priority-1 wake catch-all in `traefik/dynamic/wake.yml` forwards stopped sandboxes to `sandboxd` on port 9000 for `docker start` plus an HTML meta-refresh before the live route takes over.

## When to use exec instead of tasks

| Path | API | Best for |
|------|-----|----------|
| **Exec + preview** (this page) | `POST /sandbox/{id}/exec` | You already have code, a one-off shell command, or a dev server you start manually — no `runtimed` agent loop |
| **Agent + preview** | `POST /v1/sandboxes/{id}/tasks` | Prompt-driven builds where OpenCode/Claude writes the app and runs the dev server |

Both paths share the same preview hostname and wake behavior once something listens on the exposed port.

## Prerequisites

- Stack installed and healthy (`curl http://127.0.0.1:9090/healthz` → `ok`, `curl http://127.0.0.1:9090/readyz` → `ready`).
- `API` base URL = value of `SANDBOXED_API_BIND` (default `http://127.0.0.1:9090`).
- If `SANDBOXD_API_AUTH_DISABLED=false`, add `-H "Authorization: Bearer <secret>"` on every API call.

## End-to-end recipe

<Steps>
<Step title="Create a sandbox with an exposed port">

Declare the port your dev server will bind to inside the container. `sandboxd` emits Traefik Docker labels (`traefik.http.routers.s-{id}-{port}.rule=Host(...)`, `priority=100`) only for ports listed at create time.

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

ID=$(curl -s -XPOST "$API/sandbox" \
  -H 'content-type: application/json' \
  -d '{"ports":[3000]}' | sed -E 's/.*"id":"([^"]+)".*/\1/')
echo "sandbox=$ID"
```

Omit `id` to auto-generate a ULID; a custom `id` must be valid ULID casing or create returns `400` with `id must be a ULID`.

</Step>
<Step title="Start a dev server with exec">

:::endpoint POST /sandbox/{id}/exec
Run a command inside `s-{id}`. Non-interactive only: no PTY, no stdin (`docker exec` wrapper in `control-plane/internal/docker/docker.go`). Default response is JSON; set `"stream": true` for chunked plain text.

<ParamField body="cmd" type="string[]" required>
Argv passed to `docker exec` (e.g. `["bash","-lc","..."]`).
</ParamField>

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

<ResponseField name="stdout" type="string">Captured stdout.</ResponseField>
<ResponseField name="stderr" type="string">Captured stderr.</ResponseField>
<ResponseField name="exit_code" type="number">Process exit code; non-zero exits still return `200` with the code set.</ResponseField>
:::

Exec is **synchronous**: the HTTP request blocks until the container command exits. Background long-running servers so exec returns and you can verify the preview in a second request.

<RequestExample>
```bash
curl -s -XPOST "$API/sandbox/$ID/exec" \
  -H 'content-type: application/json' \
  -d '{"cmd":["bash","-lc","cd ~/workspace && echo hello > index.html && nohup python3 -m http.server 3000 >/tmp/http.log 2>&1 &"]}'
```
</RequestExample>

<ResponseExample>
```json
{"stdout":"","stderr":"","exit_code":0}
```
</ResponseExample>

<Note>
User project files live under `/home/sandbox/workspace` (`~/workspace` in the sandbox shell). Exec bumps `last_active_at` at start and end, which postpones the idle reaper (`SANDBOXD_IDLE_THRESHOLD_SECONDS`, default 2100s).
</Note>

</Step>
<Step title="Hit the preview locally with a Host header">

Preview URL pattern:

```
http://s-<ID>-<PORT>.preview.<PREVIEW_DOMAIN>[:<HTTP_PORT>]
```

Defaults: `PREVIEW_DOMAIN=localhost`, `HTTP_PORT=80`. Modern browsers resolve `*.localhost` to `127.0.0.1`, so you can open the URL directly. For `curl` against `127.0.0.1` you must send the preview `Host` header so Traefik selects the sandbox router:

```bash
curl -s -H "Host: s-$ID-3000.preview.localhost" "http://127.0.0.1:${HTTP_PORT:-80}/"
# expect: hello (or your app body)
```

If you changed `HTTP_PORT` in `.env` (e.g. `8088`), include it in the URL: `http://127.0.0.1:8088/` with the same `Host` header.

</Step>
<Step title="Stop the sandbox and wake it with a preview request">

Stop frees RAM while keeping the workspace on disk:

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

The next preview request to a stopped sandbox hits the wake catch-all (router priority `1` in `traefik/dynamic/wake.yml`, service `http://sandboxd:9000`). `sandboxd` runs admission, `docker start s-{id}`, optionally probes TCP readiness (default 8s, `SANDBOXD_WAKE_TCP_READY_TIMEOUT_SECONDS`), marks the row `running`, and returns the **Spinning up your app…** HTML page with a 2-second meta-refresh. After refresh, Traefik’s dynamic per-port router (priority `100`) forwards to the container.

```bash
curl -s -H "Host: s-$ID-3000.preview.localhost" "http://127.0.0.1:${HTTP_PORT:-80}/"
```

<Warning>
If nothing is listening on port 3000 inside the container, you still get the warming page or connection errors after wake. Re-run exec (or ensure your server process survived the stop/wake cycle — background servers started before stop do not automatically restart; exec again after wake if needed).
</Warning>

</Step>
<Step title="Destroy when finished">

```bash
curl -s -XPOST "$API/sandbox/$ID/purge"
```

`purge` removes the container **and** deletes the workspace. Use `DELETE /sandbox/{id}` instead to destroy the container but keep files.

</Step>
</Steps>

## Request flow (stopped → running preview)

```mermaid
sequenceDiagram
  participant Client
  participant Traefik
  participant Sandboxd as sandboxd :9000
  participant Docker
  participant Sandbox as s-{id}

  Client->>Traefik: GET / Host: s-{id}-3000.preview.localhost
  Note over Traefik: catch-all priority 1 (stopped)
  Traefik->>Sandboxd: forward (passHostHeader)
  Sandboxd->>Docker: docker start s-{id}
  Sandboxd-->>Client: 200 HTML meta-refresh 2s
  Client->>Traefik: GET / (after refresh)
  Note over Traefik: dynamic router priority 100 (running)
  Traefik->>Sandbox: :3000 dev server
  Sandbox-->>Client: HTTP response body
```

While `status` is already `running`, the wake handler returns success immediately (no `docker start`). Concurrent preview wakes for the same id dedupe through an in-memory inflight map in `control-plane/internal/wake/handler.go`.

## Exec API reference (legacy route)

| Item | Value |
|------|--------|
| Method / path | `POST /sandbox/{id}/exec` |
| Container name | `s-{id}` |
| Content-Type | `application/json` |
| Success | `200` + `execResp` JSON (or chunked stream) |
| Errors | `400` invalid JSON / missing `cmd`; `500` `docker exec: ...` |

<AccordionGroup>
<Accordion title="Streaming exec">

```bash
curl -s -XPOST "$API/sandbox/$ID/exec" \
  -H 'content-type: application/json' \
  -d '{"cmd":["bash","-lc","echo hi"],"stream":true}'
```

Response is `text/plain` with body, optional `---stderr---` section, and `exit_code: N` line.

</Accordion>
<Accordion title="Programmatic wake (no browser)">

`POST /wake/{id}` on the loopback API returns JSON `{ "id", "status": "running", "wake_duration_ms" }` when `Accept` is not routed through the preview Host shape. Preview hits remain the integrator-facing wake path for browsers and `curl` with `Host`.

</Accordion>
</AccordionGroup>

## Configuration that affects this recipe

| Variable | Default | Effect on exec preview |
|----------|---------|------------------------|
| `PREVIEW_DOMAIN` | `localhost` | Hostname segment after `.preview.` |
| `HTTP_PORT` | `80` | Host port Traefik publishes (`docker-compose.yml`) |
| `PREVIEW_ENTRYPOINT` | `web` | Traefik entrypoint on sandbox labels |
| `PREVIEW_TLS` | `false` | When `true`, use `https://` preview URLs and `websecure` |
| `SANDBOXED_API_BIND` | `127.0.0.1:9090` | Where `exec` and `stop` are called |
| `SANDBOXD_IDLE_THRESHOLD_SECONDS` | `2100` | Idle stop window; exec resets activity |

## Troubleshooting

| Symptom | Likely cause | What to check |
|---------|----------------|---------------|
| `docker exec: ...` from API | Container not running or wrong id | `GET /sandbox/{id}` → `status`; after stop, wake via preview or start server again after wake |
| `curl` to `127.0.0.1` returns Traefik 404 | Missing `Host` header | Use `Host: s-{id}-{port}.preview.{domain}` matching create-time port |
| Warming page loops | Nothing listening on exposed port | Exec a server after wake; confirm port is in create `ports` |
| `id must be a ULID` | Invalid custom id | Omit `id` on create |
| Preview works in browser but not curl | Port suffix | Append `:$HTTP_PORT` when not `80` |

## Related pages

<CardGroup>
<Card title="Quickstart (tasks API)" href="/quickstart">
Agent-driven build on port 3000 with SSE task events — alternative to manual exec.
</Card>
<Card title="Preview routing" href="/preview-routing">
Traefik Host rules, priority 100 vs wake catch-all priority 1, and `sandboxed.managed` constraint.
</Card>
<Card title="Wake, idle, and pressure" href="/wake-idle-reapers">
Stop-on-idle, admission, TCP-ready probe, and warming-page behavior in depth.
</Card>
<Card title="Preview URL reference" href="/preview-url-reference">
Exact hostname pattern and `HTTP_PORT` suffix rules.
</Card>
<Card title="Legacy API reference" href="/legacy-api-reference">
Full `/sandbox*` contract including exec, keepalive, and purge.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
readyz, port 80 conflicts, ULID validation, and warming-page stalls.
</Card>
</CardGroup>
