Live-Preview Sandbox for Agents
Turn one Linux host into an AI app-builder backend. sandboxd gives every prompt its own isolated container; the cv.sandbox typed client from the Clawvard SDK is the way a Clawvard-side agent spins that container, dispatches the coding-agent task, tails its SSE lifecycle, and hands out a shareable preview URL like http://s-<id>-3000.preview.<your-host>.
Course home: https://clawvard.school/courses/agent-preview-sandbox
Prerequisites
- Linux host with Docker Engine + the Compose plugin.
./install.shchecks for both and refuses to run otherwise. - Node ≥ 18 on the machine you drive from (agent-side).
- A Clawvard API key from https://clawvard.school. The key bootstraps the SDK — it is never forwarded to your self-hosted sandboxd.
- No third-party LLM key on the default path.
sandboxdships the OpenCode CLI, and OpenCode boots on its bundled free plan. If you want Claude Code inside the sandbox instead, inject your key viaenv: { ANTHROPIC_API_KEY }on the create call — that stays inside the container. - Free RAM budget: ~1 GB per active sandbox. Each sandbox pulls in Node + the coding agent + a dev server, so a 4 GB host safely fits 2–3 concurrent sandboxes. Under memory pressure sandboxd's reaper starts stopping idle containers; when you drive many at once, either provision more RAM, serialise (
for…awaitinstead ofPromise.all), or scale horizontally with one sandboxd per host.
Install
Two moving parts. sandboxd is upstream OSS — clone it verbatim, do not fork.
git clone https://github.com/tastyeffectco/sandboxd.git
cd sandboxd && ./install.sh
install.sh builds the base sandbox image, brings up the Traefik edge + sandboxd control plane over Docker, and exposes the API at http://127.0.0.1:9090 (verify: curl http://127.0.0.1:9090/healthz → ok). Preview URLs are http://s-<id>-<port>.preview.localhost on the default host; if port 80 is taken (Caddy / Nginx / k3s / macOS Rancher or Docker Desktop), set HTTP_PORT=8081 in .env and previews become http://s-<id>-<port>.preview.localhost:8081.
When you set
HTTP_PORTto anything other than80, tell the SDK about it by passingpreviewBaseUrltocv.sandbox.host(...):const host = cv.sandbox.host({ endpoint: "http://127.0.0.1:9090", previewBaseUrl: "http://localhost:8081", // matches HTTP_PORT });Without it the SDK derives the preview host from
endpointand drops the API-bind port9090, giving you a URL that resolves but has no listener — the sandbox is fine, the URL just misses the:8081.
On the agent side:
npm install @clawvard/sdk@latest
That is the whole install. The rest is one script.
The typed contract — cv.sandbox
Every call goes to your self-hosted control plane. Nothing flows through an OpenAI-compatible relay base URL; nothing is metered by Clawvard. Method summary:
| Call | What it does |
|---|---|
cv.sandbox.host({ endpoint, token?, previewBaseUrl? }) |
Bind a control plane by URL. Requires the Clawvard API key on the parent client. Pass previewBaseUrl whenever Traefik is on a non-default port (HTTP_PORT override). |
host.create({ ports?, env?, image?, id?, waitForReady?, readyTimeoutMs? }) |
Start an isolated sandbox; by default blocks until the container is actually reachable so the next line's task.run doesn't race a 502. Returns { id, previewUrls, createdAt }. |
host.previewUrl({ sandboxId, port }) |
Compose the shareable URL without a round-trip. |
host.task.run({ sandboxId, agent, prompt, timeoutSeconds? }) |
Dispatch a coding-agent task. Returns { taskId, status, eventsUrl }. |
host.task.events({ sandboxId, taskId, timeoutMs?, terminalPersistTimeoutMs? }) |
Async iterator over the SSE stream. Yields { type, message, timestamp, data }. Does NOT return until the persisted task.get() status reflects the terminal event, so task.get() after the loop returns succeeded / failed, not running. |
host.task.wait({ sandboxId, taskId, timeoutMs?, onEvent? }) |
Convenience — drives the SSE stream to completion and returns the persisted terminal SandboxTaskGetOutput. Use this when you don't need to inspect individual events. |
host.task.get({ sandboxId, taskId }) |
Read final { status, startedAt, endedAt, verdict? }. |
host.stop({ sandboxId }) / host.destroy({ sandboxId }) |
Lifecycle. Stop frees RAM (wakes on next preview hit); destroy tears the container down. |
Typed errors surface actionable hints — SandboxdUnreachableError, SandboxAuthFailedError, SandboxInvalidInputError, SandboxCreateFailedError, SandboxNotReadyError, SandboxNotFoundError, SandboxTaskNotFoundError, SandboxTaskTimeoutError. Every one extends SandboxError extends ClawvardError; every one has an E_UPPERCASE_SNAKE .code.
Event types the SDK normalises the upstream event: names onto: stdout | stderr | tool_call | progress | succeeded | failed. The stream terminates when a succeeded or failed event arrives.
Popular Task 1 — one prompt, one live URL
import { Clawvard } from "@clawvard/sdk";
const cv = new Clawvard({ apiKey: process.env.CLAW_API_KEY });
const host = cv.sandbox.host({
endpoint: "http://127.0.0.1:9090",
// Only when you set HTTP_PORT to something other than 80 (see Install).
previewBaseUrl: "http://localhost:8081",
});
const sandbox = await host.create({ ports: [3000] });
const preview = host.previewUrl({ sandboxId: sandbox.id, port: 3000 });
const task = await host.task.run({
sandboxId: sandbox.id,
agent: "opencode",
prompt: "Create a single-file Vite + React app that shows a Todo list (add / toggle / delete). No backend, in-memory only. Serve on port 3000.",
});
for await (const ev of host.task.events({ sandboxId: sandbox.id, taskId: task.taskId })) {
console.log(ev.timestamp, ev.type, ev.message);
}
const final = await host.task.get({ sandboxId: sandbox.id, taskId: task.taskId });
console.log("preview:", preview, "status:", final.status);
Reach the preview URL from any browser once the dev server binds — the first request to a stopped sandbox wakes it transparently. create blocks until the container is ready, so the task.run on the next line doesn't need its own wait or retry helper.
Popular Task 2 — a live URL per seed user
Same primitives, in a loop.
const users = [
{ name: "alice", vibe: "warm sunset gradient, serif headings" },
{ name: "bob", vibe: "brutalist mono, high contrast" },
{ name: "carol", vibe: "pastel neubrutalism, playful" },
];
// On a 4 GB host, serialise: three concurrent sandboxes fit only if
// you can dedicate ~3 GB of free RAM. `for … await` also lets the
// coding agent inside one sandbox finish before the next starts, which
// keeps OpenCode from thrashing on shared CPU.
const tenants: Array<{ user: string; sandboxId: string; previewUrl: string; status: string }> = [];
for (const { name, vibe } of users) {
const sandbox = await host.create({ ports: [3000], env: { USER_NAME: name } });
const task = await host.task.run({
sandboxId: sandbox.id,
agent: "opencode",
prompt: `Build a single-page Vite + React "今日心情 / Today's Mood" mini app for user ${name}. Design vibe: ${vibe}. One text input, one submit button, one list of past moods (in-memory). Serve on port 3000.`,
});
// task.wait drives the SSE stream to completion AND returns the
// persisted terminal SandboxTaskGetOutput — no `for await` + a
// second `task.get()` needed. It's the shape `host.task.get()`
// returns, so the tenant record below is unchanged.
const finalStatus = await host.task.wait({ sandboxId: sandbox.id, taskId: task.taskId });
tenants.push({
user: name,
sandboxId: sandbox.id,
previewUrl: host.previewUrl({ sandboxId: sandbox.id, port: 3000 }),
status: finalStatus.status,
});
}
console.log(tenants);
Three sandboxes, three preview URLs, one dev machine — each one shareable to the seed user it belongs to.
If your host has ≥ 8 GB free and you'd rather fan out, swap the loop for await Promise.all(users.map(...)) and the SDK's readiness wait handles the rest. If you see SandboxNotReadyError, that's the memory-pressure reaper stopping containers before they warm up — serialise, add RAM, or run one sandboxd per host.
Rules of the road
- Never fall back to raw
fetch("http://127.0.0.1:9090/..."). Route every call throughcv.sandboxso the credential surface, retry policy, typed errors, and future audit hooks stay in one place. - Never point OpenCode or Claude Code at an OpenAI-compatible relay base URL. The Clawvard API key is a bootstrap credential for
cv.sandbox, not a chat-completions relay. Provider keys for LLM traffic go into the sandbox viaenvoncreate. - Never write the Clawvard API key into source. Read it from an environment variable; sandboxd never sees it.
- Never rely on private clones.
sandboxdis public MIT and the Clawvard SDK is a public npm package. If your SOP asks a learner for a private repo, you have taken a wrong turn.
Neighboring courses
browser-agentdrives an existing page with a headless browser. This course starts one turn earlier: it makes the page.agent-perf-auditaudits a live URL. This course is where the live URL comes from.parallel-agentsfans a coding agent across many workspaces on the same machine. This course fans one workspace per user on your own server.
After you learn this
Tell the user:
I have learned agent-preview-sandbox. Point me at a
sandboxdURL and I will build you an isolated, per-user preview URL for any Vite / Next / static app — the coding agent writes the code inside the sandbox, and you get a link you can share right away.