# Build a todo app with an agent

> End-to-end recipe: create sandbox on port 3000, submit opencode task prompt, stream task events, verify preview URL, optional ANTHROPIC_API_KEY via env at create.

- 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/v1_tasks.go`
- `control-plane/internal/api/handlers.go`
- `image/Dockerfile`

---

---
title: "Build a todo app with an agent"
description: "End-to-end recipe: create sandbox on port 3000, submit opencode task prompt, stream task events, verify preview URL, optional ANTHROPIC_API_KEY via env at create."
---

The `POST /v1/sandboxes/{id}/tasks` route on `sandboxd` submits an OpenCode prompt to `runtimed` inside a sandbox container, wakes a stopped sandbox first, streams progress as Server-Sent Events, and exposes the built app at `http://s-{ulid}-3000.preview.{PREVIEW_DOMAIN}` once the in-sandbox Vite dev server on port 3000 is healthy.

## Prerequisites

| Requirement | Notes |
|---|---|
| Linux host with Docker Engine + Compose | `./install.sh` builds `sandboxed-base` and starts `sandboxd` + Traefik |
| Control plane reachable | Default `SANDBOXED_API_BIND=127.0.0.1:9090` — verify `curl -s http://127.0.0.1:9090/healthz` returns `ok` |
| Port 3000 exposed at create | `ports` in `POST /sandbox` registers Traefik routers for preview |
| Optional provider key | `env.ANTHROPIC_API_KEY` at create; visible to `runtimed` and agent subprocesses |

<Note>
API auth is off by default (`SANDBOXD_API_AUTH_DISABLED=true`). For non-loopback use, set `SANDBOXD_API_AUTH_DISABLED=false` and send `Authorization: Bearer <secret>` on every API call.
</Note>

## Request flow

```mermaid
sequenceDiagram
    participant Client
    participant sandboxd
    participant SQLite
    participant runtimed
    participant OpenCode
    participant Traefik

    Client->>sandboxd: POST /sandbox ports=[3000] env?
    sandboxd->>SQLite: persist sandbox row
    sandboxd-->>Client: id, status

    Client->>sandboxd: POST /v1/sandboxes/{id}/tasks
    alt sandbox stopped
        sandboxd->>sandboxd: wake via internal /wake/{id}
    end
    sandboxd->>runtimed: POST /tasks (UDS)
    runtimed->>OpenCode: opencode run --format json
    sandboxd->>SQLite: CreateTask running
    sandboxd-->>Client: 202 task id, events_url

    Client->>sandboxd: GET .../tasks/{taskId}/events (SSE)
    sandboxd->>runtimed: stream events.jsonl
    runtimed-->>Client: status, message, tool, build, done

    runtimed->>runtimed: pnpm dev on :3000
    Client->>Traefik: Host s-{id}-3000.preview.localhost
    Traefik->>runtimed: forward :3000
```

Coding work happens under `/home/sandbox/workspace/app` (`RUNTIMED_APP_DIR`). `runtimed` is the container main process: it supervises `pnpm dev` (default port 3000) and runs at most one active task per sandbox.

## Full recipe

Set the API base URL once:

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

<Steps>
<Step title="Create a sandbox on port 3000">

Expose port 3000 so Traefik registers `s-{id}-3000.preview.{domain}`.

<RequestExample>
```bash
curl -s -XPOST "$API/sandbox" \
  -H 'content-type: application/json' \
  -d '{"ports":[3000]}'
```
</RequestExample>

<ResponseExample>
```json
{"id":"01JXXXXXXXXXXXXXXXXXXXXXX","status":"running","ports":[3000],...}
```
</ResponseExample>

Capture the sandbox ULID:

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

</Step>

<Step title="Submit the OpenCode task">

Send a prompt that asks for a Vite todo app and to serve it on port 3000. The default agent is `opencode`; only `opencode` is accepted today.

:::endpoint POST /v1/sandboxes/{id}/tasks
Submit a headless coding task. Wakes a stopped sandbox before submit. Returns `202 Accepted`.
:::

<ParamField body="prompt" type="string" required>
Natural-language task for the agent (non-empty).
</ParamField>

<ParamField body="agent" type="string">
Agent adapter name. Defaults to `opencode`. Other values return `400 invalid_request`.
</ParamField>

<RequestExample>
```bash
TASK=$(curl -s -XPOST "$API/v1/sandboxes/$ID/tasks" \
  -H 'content-type: application/json' \
  -d '{
    "prompt": "create a Vite app that shows a todo list and run it on port 3000",
    "agent": "opencode"
  }')
echo "$TASK"
TASK_ID=$(echo "$TASK" | sed -E 's/.*"id":"([^"]+)".*/\1/')
```
</RequestExample>

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

<Warning>
Only one task may run per sandbox. A concurrent submit returns `409` with `code: task_in_progress`.
</Warning>

</Step>

<Step title="Stream task events (SSE)">

Follow `events_url` with `curl -N` to receive Server-Sent Events. Resume with `Last-Event-ID` or `?since=<n>`.

<RequestExample>
```bash
curl -N "$API/v1/sandboxes/$ID/tasks/$TASK_ID/events"
```
</RequestExample>

| Event type | Role |
|---|---|
| `status` | Phase transitions inside `runtimed` |
| `message` | Agent text (best-effort, from OpenCode JSON stream) |
| `tool` | Tool invocations (`read`, `write`, `bash`, …) |
| `build` | Post-task `pnpm build` check |
| `done` | Terminal; `data` carries canonical `TaskResult` |

Poll the durable result without keeping SSE open:

```bash
curl -s "$API/v1/sandboxes/$ID/tasks/$TASK_ID"
```

While running, the response is `{"id":"...","sandbox_id":"...","status":"running"}`. When finished, fields include `status` (`succeeded` | `failed` | `cancelled`), `build_ok`, `files_changed`, and token usage.

</Step>

<Step title="Open and verify the preview URL">

Preview hostname pattern (from Traefik labels):

```
http://s-{id}-3000.preview.{PREVIEW_DOMAIN}
```

Defaults: `PREVIEW_DOMAIN=localhost`, `HTTP_PORT=80`. Modern browsers resolve `*.localhost` to `127.0.0.1`.

| Setting | Local URL |
|---|---|
| Defaults | `http://s-$ID-3000.preview.localhost` |
| `HTTP_PORT=8088` | `http://s-$ID-3000.preview.localhost:8088` |
| Production + TLS | `https://s-$ID-3000.preview.yourdomain.com` |

Verify from the shell (Traefik on loopback):

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

<Info>
A stopped sandbox shows the warming page ("Spinning up your app…") until wake completes. The dev server may still be starting after the task finishes — poll until HTTP 200 and expected HTML.
</Info>

Inspect in-sandbox preview health via the legacy get endpoint:

```bash
curl -s "$API/sandbox/$ID" | jq '.runtime.preview'
```

`preview.status` is `down`, `starting`, or `ready`.

</Step>

<Step title="Clean up (optional)">

```bash
# stop container, keep workspace
curl -s -XPOST "$API/v1/sandboxes/$ID/stop"

# destroy container + delete workspace
curl -s -XPOST "$API/sandbox/$ID/purge"
```

</Step>
</Steps>

## Inject `ANTHROPIC_API_KEY` at create

OpenCode and Claude Code ship in the base image (`image/Dockerfile`). Keys belong in the create payload so both the tasks API and any `exec` session see them:

<RequestExample>
```bash
ID=$(curl -s -XPOST "$API/sandbox" -H 'content-type: application/json' \
  -d '{
    "ports": [3000],
    "env": {"ANTHROPIC_API_KEY": "sk-ant-..."}
  }' | sed -E 's/.*"id":"([^"]+)".*/\1/')

curl -s -XPOST "$API/v1/sandboxes/$ID/tasks" -H 'content-type: application/json' \
  -d '{"prompt":"build a Vite todo app and run it on port 3000","agent":"opencode"}'
```
</RequestExample>

<ParamField body="env" type="object">
Map of environment variables passed to `docker run --env`. Keys must be non-empty and must not contain `=` or newlines. Values are visible inside the container to `runtimed` and spawned agents.
</ParamField>

<Tip>
Without a key, OpenCode can still run on its bundled free plan. Inject a key when you want your own provider account and quotas.
</Tip>

## Task outcome fields

When `GET /v1/sandboxes/{id}/tasks/{taskId}` returns a completed task, the embedded result follows `runtime.TaskResult`:

| Field | Meaning |
|---|---|
| `status` | `succeeded`, `failed`, or `cancelled` |
| `build_ok` | Whether post-task `pnpm build` passed |
| `files_changed` | Paths from git diff against pre-task checkpoint |
| `agent_message_final` | Last agent message text |
| `preview_status_after` | Preview health after the task |
| `failure_reason` / `error_message` | Set when `status` is `failed` |

Results persist in SQLite even after `stop` or `delete` on the sandbox.

## Common errors

| HTTP | `error.code` | Cause |
|---|---|---|
| 400 | `invalid_request` | Missing `prompt`, bad JSON, or unsupported `agent` |
| 404 | `not_found` | Unknown sandbox or task |
| 409 | `conflict` | Sandbox not `running` after wake attempt |
| 409 | `task_in_progress` | Another task is active |
| 502 | `sandbox_unavailable` | `runtimed` socket unreachable |
| 503 | `sandbox_capacity` | Wake refused (host memory admission) |

v1 errors use `{"error":{"code","message","retryable"}}`; `retryable` is true for 502/503.

## Troubleshooting this recipe

| Symptom | Check |
|---|---|
| Warming page never finishes | `docker compose logs -f sandboxd`; sandbox may still be waking or nothing listens on 3000 yet |
| Task `failed` / `build_ok: false` | Stream events for `build` and `done`; inspect `build_error_message` |
| Preview 502 after `succeeded` | `preview_error_message` on task result — dev server up but app assets unhealthy |
| `id must be a ULID` on create | Omit custom `id` or supply a valid ULID |
| Preview needs `:8088` | Set `HTTP_PORT` in `.env` and include the port in browser and curl URLs |

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Shorter copy-paste path for create → task → preview without the todo-specific prompt.
</Card>
<Card title="Run coding agents" href="/run-coding-agents">
Task API contract, wake-on-submit, SSE resume, and `runtimed` bridge details.
</Card>
<Card title="Preview URL reference" href="/preview-url-reference">
Hostname pattern, `HTTP_PORT` suffix rules, and localhost vs production TLS.
</Card>
<Card title="Installation" href="/installation">
First-time `./install.sh`, `.env` bootstrap, and `healthz` / `readyz` checks.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Control-plane logs, port conflicts, ULID validation, and warming-page stalls.
</Card>
</CardGroup>
