# runtimed reference

> In-sandbox supervisor HTTP over Unix socket: GET /status, POST /tasks, GET /tasks/{id}/events (SSE), POST /tasks/{id}/cancel; workspace paths and sandboxd runtime.Client bridge.

- 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/cmd/runtimed/server.go`
- `control-plane/cmd/runtimed/task.go`
- `control-plane/internal/runtime/client.go`
- `image/Dockerfile`
- `image/HOME_LAYOUT.md`
- `ARCHITECTURE.md`

---

---
title: "runtimed reference"
description: "In-sandbox supervisor HTTP over Unix socket: GET /status, POST /tasks, GET /tasks/{id}/events (SSE), POST /tasks/{id}/cancel; workspace paths and sandboxd runtime.Client bridge."
---

`runtimed` is the main process inside every sandbox container (`tini` → `/usr/local/bin/runtimed`). It supervises the dev server, runs at most one coding task at a time, and exposes an HTTP/1.1 control API on a Unix domain socket under the durable workspace. The host control plane (`sandboxd`) reaches that socket through the workspace bind mount via `internal/runtime.Client`; integrators normally use the public `/v1/sandboxes/{id}/tasks` surface, which proxies to runtimed and reframes the event stream as SSE.

## Placement in the stack

```mermaid
flowchart TB
  subgraph host["Host — sandboxd"]
    API["/v1/sandboxes/{id}/tasks"]
    RC["runtime.Client"]
    DB[(SQLite task rows)]
    Watcher[taskwatch goroutine]
  end
  subgraph ws["Workspace mount — &lt;id&gt;.mnt"]
    SOCK[".runtimed/sock"]
    TASKS[".runtimed/tasks/&lt;taskId&gt;/"]
  end
  subgraph container["Sandbox container — runtimed"]
    RT["runtimed HTTP mux"]
    DEV["dev server supervisor"]
    AG["OpenCode agent"]
  end
  API --> RC
  RC -->|"unix HTTP"| SOCK
  SOCK --> RT
  RT --> DEV
  RT --> AG
  Watcher --> RC
  Watcher --> DB
  RT --> TASKS
```

| Layer | Responsibility |
|---|---|
| `runtimed` | Dev-server lifecycle, preview probing, task execution, on-disk task artifacts |
| `runtime.Client` | Host-side dialer over `<workspaces>/<id>.mnt/.runtimed/sock` |
| `internal/api/v1_tasks.go` | Wake-on-submit, ULID allocation, SSE translation, SQLite persistence |
| `internal/api/taskwatch.go` | Background NDJSON consumer; writes canonical `TaskResult` to SQLite |

<Note>
`runtimed` is never exposed on the container network. Preview traffic goes through Traefik to the dev server port; control traffic stays on the workspace UDS.
</Note>

## Socket and workspace paths

| Path | Role |
|---|---|
| `/home/sandbox/.runtimed/sock` | Control socket inside the container (`runtime.DefaultSocketPath`) |
| `<SANDBOXED_DATA_DIR>/workspaces/<id>.mnt/.runtimed/sock` | Same inode on the host (loopback/bind mount) |
| `/home/sandbox/.runtimed/tasks/<taskId>/` | Per-task directory (`events.jsonl`, `result.json`, `agent.log`) |
| `/home/sandbox/workspace/app` | App working directory (`RUNTIMED_APP_DIR`); dev server and agent `chdir` here |
| `/home/sandbox/workspace` | User project root (see `image/HOME_LAYOUT.md`) |

The `.runtimed/` subtree is reserved: v1 file writes reject paths under `.runtimed/` so clients cannot corrupt the supervisor state.

### Environment variables

| Variable | Default | Effect |
|---|---|---|
| `RUNTIMED_APP_DIR` | `/home/sandbox/workspace/app` | Dev-server and agent working directory |
| `RUNTIMED_DIR` | `/home/sandbox/.runtimed` | Runtime state root |
| `RUNTIMED_SOCKET` | `<RUNTIMED_DIR>/sock` | UDS bind path |
| `RUNTIMED_DEV_CMD` | `pnpm dev` | Dev-server command (`bash -lc`) |
| `RUNTIMED_PREVIEW_PORT` | `3000` | HTTP probe target (not the control socket) |
| `RUNTIMED_PROBE_INTERVAL_SECONDS` | `3` | Preview health poll interval |

On boot, `runtimed` creates `RUNTIMED_DIR` and `RUNTIMED_APP_DIR` if missing, recovers interrupted tasks, starts dev-server supervision, and binds the socket (stale socket files are removed before listen).

## Control API (Unix HTTP)

All routes are served by Go 1.22+ `http.ServeMux` method patterns on the workspace socket. Clients use a synthetic host (`http://runtimed/...`) with `net.Dial("unix", socketPath)` — the same pattern as `runtime.Client`.

| Method | Path | Success | Purpose |
|---|---|---|---|
| `GET` | `/status` | `200` | Full supervisor snapshot (`runtime.Status`) |
| `POST` | `/tasks` | `202` | Start a coding task |
| `GET` | `/tasks/{id}/events` | `200` | Live or replayed event stream (NDJSON) |
| `POST` | `/tasks/{id}/cancel` | `200` | Cancel active task (idempotent) |

### `GET /status`

Returns JSON matching `runtime.Status`:

<ResponseField name="runtimed" type="object">
Supervisor identity: `version`, `booted_at`, `uptime_s`.
</ResponseField>

<ResponseField name="preview" type="object">
Reported dev-server state — never commanded. Fields include `status` (`down` | `starting` | `ready` | `error`), optional `pid`, `last_http_status`, `last_checked_at`, `build_error_message`, `restarts`.
</ResponseField>

<ResponseField name="active_task" type="object | null">
When a task is in flight: `id`, `status` (`running`), `phase`. Null when idle or after the task finishes.
</ResponseField>

A connection error from `runtime.Client.Status` (socket missing or refused) means the sandbox is stopped or runtimed has not finished booting.

### `POST /tasks`

<ParamField body="task_id" type="string" required>
Caller-supplied task identifier (sandboxd uses a ULID on the v1 path).
</ParamField>

<ParamField body="prompt" type="string" required>
Agent instruction text.
</ParamField>

<ParamField body="agent" type="string">
Agent adapter name. Empty or `"opencode"` selects OpenCode; other values return `400`.
</ParamField>

<ParamField body="env" type="object">
Key/value map injected into the agent process (e.g. provider API keys from sandbox create).
</ParamField>

<ParamField body="timeout_s" type="integer">
Task timeout in seconds; default **600** (10 minutes) when omitted or zero.
</ParamField>

<RequestExample>

```json
{
  "task_id": "01JABCDEFGHJKMNPQRSTVWXYZ0",
  "prompt": "Add a greeting component and wire it into the app",
  "agent": "opencode",
  "env": { "ANTHROPIC_API_KEY": "sk-ant-..." }
}
```

</RequestExample>

<ResponseExample>

```json
{ "task_id": "01JABCDEFGHJKMNPQRSTVWXYZ0", "status": "running" }
```

</ResponseExample>

| HTTP status | Meaning |
|---|---|
| `202 Accepted` | Task started |
| `409 Conflict` | Another task is active (`error`: `task_in_progress`, `active_task_id`) |
| `400 Bad Request` | Invalid JSON, missing fields, or unsupported agent |

Exactly **one** active task per sandbox is enforced in `startTask`.

### `GET /tasks/{id}/events`

Streams **newline-delimited JSON** (`Content-Type: application/x-ndjson`), not SSE at this layer.

<ParamField query="since" type="integer">
Event index to resume from (default `0`). For live tasks, only values `> 0` are honored when parsing the query string.
</ParamField>

Each line is a `runtime.Event`:

| Field | Type | Notes |
|---|---|---|
| `id` | int | Monotonic index |
| `type` | string | `status`, `message`, `tool`, `build`, `done` |
| `ts` | RFC3339 time | Event timestamp |
| `data` | JSON | Type-specific payload |

The stream ends after the terminal `done` event. Past tasks replay from `.runtimed/tasks/<id>/events.jsonl`; unknown IDs return `404`.

`runtime.Client` uses a **no-timeout** HTTP client for this route; status/start/cancel use a **5s** timeout.

### `POST /tasks/{id}/cancel`

Always returns `200` with `{"task_id":"<id>","status":"cancelling"}` — idempotent if the ID does not match the active task. Cancellation kills the agent process group; the task finalizes as `cancelled` (build/health checks are skipped on cancel).

## Task lifecycle

```mermaid
stateDiagram-v2
  [*] --> queued: POST /tasks
  queued --> checkpoint: runTask
  checkpoint --> agent_running: git checkpoint
  agent_running --> build_check: agent returns
  build_check --> health_check: not cancelled
  health_check --> done: probe preview
  agent_running --> done: timeout / error / cancel
  build_check --> done: failure
  health_check --> done: finish
  done --> [*]: EventDone + result.json
```

| Phase (`active_task.phase`) | Work |
|---|---|
| `starting` | Task accepted |
| `checkpoint` | Pre-task git commit in `RUNTIMED_APP_DIR` |
| `agent_running` | OpenCode (`opencode run --format json`) |
| `build_check` | `pnpm build` (skipped if cancelled) |
| `health_check` | Post-task dev-server remediation and entry-asset probe |
| `done` | Terminal; `active_task` cleared from `/status` |

Terminal outcomes in `runtime.TaskResult` (`status` on the `done` event and `result.json`):

| `status` | Typical `failure_reason` |
|---|---|
| `succeeded` | — |
| `failed` | `agent_timeout`, `agent_error`, `internal`, `sandbox_unavailable` |
| `cancelled` | `cancelled` |

Authoritative `files_changed` comes from `git diff` against the checkpoint, not from agent stream events. `build_ok` / `build_error_message` reflect the build check; `preview_status_after` and `preview_error_message` reflect live preview health after the task.

### On-disk artifacts

```
.runtimed/
├── sock
├── dev-server.log
└── tasks/<taskId>/
    ├── events.jsonl    # append-only canonical log
    ├── result.json     # written at terminal done
    └── agent.log       # raw agent stdout
```

On `runtimed` boot, any directory with `events.jsonl` but no `result.json` is finalized as `failed` / `sandbox_unavailable` — interrupted tasks are **never resumed**.

## `runtime.Client` bridge (sandboxd)

`runtime.NewClient(socketPath)` dials the workspace socket and speaks the same HTTP routes:

| Client method | runtimed route | Notes |
|---|---|---|
| `Status(ctx)` | `GET /status` | Returns `*Status` or connection error |
| `StartTask(ctx, StartTaskRequest)` | `POST /tasks` | `ErrTaskInProgress` on `409` |
| `TaskEvents(ctx, taskID, since)` | `GET /tasks/{id}/events?since=N` | `io.ReadCloser` of NDJSON |
| `CancelTask(ctx, taskID)` | `POST /tasks/{id}/cancel` | Idempotent at runtimed |

Construction from a sandbox ID:

```go
_, mnt := loopback.Paths(id)
runtime.NewClient(filepath.Join(mnt, ".runtimed", "sock"))
```

`GET /sandbox/{id}` and v1 sandbox `get` merge `runtime.Status` into the response `runtime` block when the socket is reachable.

### Background watcher

After `POST /v1/sandboxes/{id}/tasks`, `sandboxd` starts `watchTask`, which opens `TaskEvents` from index `0`, decodes until `type: done`, and persists `TaskResult` to SQLite — independent of any client SSE connection. On `sandboxd` restart, `ReconcileTasks` prefers `result.json` on disk, re-attaches a watcher if the sandbox is still running, or marks `sandbox_unavailable`.

## Public v1 mapping

Integrators should use the v1 API; it wraps the same protocol:

| v1 route | runtimed / store behavior |
|---|---|
| `POST /v1/sandboxes/{id}/tasks` | Wake stopped sandbox, allocate ULID, `StartTask`, SQLite row + watcher |
| `GET /v1/sandboxes/{id}/tasks/{taskId}` | SQLite canonical result (works after stop/destroy) |
| `GET /v1/sandboxes/{id}/tasks/{taskId}/events` | Proxies NDJSON → **SSE** (`text/event-stream`); resume via `Last-Event-ID` or `?since=` |
| `POST /v1/sandboxes/{id}/tasks/{taskId}/cancel` | `CancelTask` |

v1 submit accepts `prompt` and optional `agent` (default `opencode`); `task_id` and `env` are not set on the public body — env comes from sandbox create injection into the container environment.

<Warning>
At the runtimed layer, events are NDJSON. Only the v1 `events` endpoint speaks SSE. Do not point SSE clients directly at the Unix socket.
</Warning>

## Direct socket debugging

From the host, with the workspace mounted at `$MNT`:

```bash
curl --unix-socket "$MNT/.runtimed/sock" http://runtimed/status
curl --unix-socket "$MNT/.runtimed/sock" -X POST http://runtimed/tasks \
  -H 'Content-Type: application/json' \
  -d '{"task_id":"debug-1","prompt":"echo hello > src/hello.txt","agent":"opencode"}'
curl -N --unix-socket "$MNT/.runtimed/sock" \
  'http://runtimed/tasks/debug-1/events?since=0'
```

Expect `preview.status` to reach `ready` within a few seconds on a healthy template sandbox.

## Limits and deferred behavior

| Topic | Current behavior |
|---|---|
| Agents | OpenCode only (`selectAgent` in `task.go`) |
| Concurrency | One active task per sandbox |
| Event retention after destroy | Full NDJSON log lives in workspace; only **result** is retained in SQLite after destroy |
| `tool` / `file_change` events | Protocol constants exist; OpenCode adapter surfaces `message` events today |
| Dev server on `package.json` change | No automatic restart after dependency edits |

Shared types and protocol comments live in `control-plane/internal/runtime` (`protocol.go`, `tasks.go`, `client.go`). The in-tree operator guide is `control-plane/cmd/runtimed/README.md`.

## Related pages

<CardGroup>
<Card title="Run coding agents" href="/run-coding-agents">
Submit tasks via v1, env injection, and integrator-facing SSE streaming.
</Card>
<Card title="v1 API reference" href="/v1-api-reference">
Public task request/response shapes and error envelope.
</Card>
<Card title="Workspaces and persistence" href="/workspaces-persistence">
Bind mounts, skeleton seeding, and what survives stop/destroy.
</Card>
<Card title="Overview" href="/overview">
End-to-end create → task → preview path across sandboxd, runtimed, and Traefik.
</Card>
</CardGroup>
