Workdir
Workdir runs low-cost Firecracker microVM sandboxes for agent workloads. This is the REST API reference: create a sandbox, run commands, read and write files, expose ports, drive a real browser, and let idle sandboxes park at $0 and auto-resume on the next request.
For the product, see workdir.dev.
Overview
- Base URL:
https://api.<domain>(dev:http://127.0.0.1:8080). - Auth:
Authorization: Bearer sk_live_...on every/v1route. See Authentication for how to get a key. - All bodies are JSON. Errors are
{"error": {"code", "message", "reason?"}}.
The default create is one call with no body and yields the cheapest, fastest path.
Authentication
The /v1 API uses a bearer token. Create an account and an API key from the Workdir dashboard at workdir.dev, then send it on every request.
- Sign up at workdir.dev with an email and password (or GitHub, where enabled). This creates your org and signs you in.
- Create a key from the dashboard (optionally name it). Workdir shows the full key —
sk_live_followed by 48 hex characters — once. Copy it then: only a hash is stored, so it cannot be shown again. - Use it as
Authorization: Bearer sk_live_...againsthttps://api.workdir.devon every/v1request.
curl https://api.workdir.dev/v1/sandboxes \
-X POST \
-H "Authorization: Bearer sk_live_..." \
-d '{}'Revoke a key from its detail page in the dashboard; the API stops accepting it right away. A key is tied to your org — every sandbox, secret, and preview route it creates belongs to that org. There is no way to recover a lost key: revoke it and create a new one.
Sandboxes
| Method | Path | Notes |
|---|---|---|
POST | /v1/sandboxes | Create. Empty body = default cheap path. |
GET | /v1/sandboxes | List the caller's sandboxes. |
GET | /v1/sandboxes/:id | Get one (with timings, urls, price, uptime, cost). |
POST | /v1/sandboxes/:id/exec | {cmd, cwd?, env?, background?} → {exit_code, stdout, stderr}. |
GET | /v1/sandboxes/:id/pty | WebSocket interactive shell (a real in-guest TTY over vsock on Firecracker). |
GET | /v1/sandboxes/:id/metrics | Working-set metrics: host RSS vs reserved shape, balloon target + guest memory stats, net counters. |
GET | /v1/sandboxes/:id/files?path=… | Read a file → {content, encoding}. |
PUT | /v1/sandboxes/:id/files | {path, content, encoding?} → write. |
POST | /v1/sandboxes/:id/ports/:port/expose | → {port, url} preview route. |
GET/POST | /v1/sandboxes/:id/browser | Browser readiness + VNC/CDP urls + screenshot url. |
GET | /v1/sandboxes/:id/browser/screenshot | PNG of the live desktop (X root window). |
POST | /v1/sandboxes/:id/snapshot | Snapshot (billed separately). |
POST | /v1/sandboxes/:id/fork | Clone an instant sibling from the parent’s live state (boot_path: "fork"). |
POST | /v1/sandboxes/:id/pause | Stop (release CPU/mem; keep billing correct). |
POST | /v1/sandboxes/:id/resume | Resume from stopped disk/snapshot. |
DELETE | /v1/sandboxes/:id | Stop, delete ephemeral disk, remove routes. |
Lifecycle & perpetual standby
States: creating → running → stopping → {stopped|standby} → resuming → running, plus deleting → deleted and failed.
stoppedis a user-initiated pause (POST .../pause) and requires an explicitPOST .../resume.standbyis automatic: when a sandbox is idle pastauto_stop_seconds, the reaper snapshots it, frees its RAM, and parks it instandbyat $0. The next request (exec, file read/write,expose,fork) transparently auto-resumes it — the caller just sees a slightly slower first call. To the user the sandbox stays alive; it simply stops costing anything while idle. (Sandboxes with resident secrets are never snapshotted, so they fall back to a plainstoppedinstead.)
Create request
// default cheap path
{}
// or "startup": "none" explicitly
{ "startup": "none" }
// heavier path — explicit options required (spec §3.4)
{
"image": "browser",
"resources": { "cpu": 2, "memory_mb": 4096, "disk_gb": 16 },
"browser": { "enabled": true, "vnc": true, "cdp": true },
"auto_stop_seconds": 300,
"snapshot": false,
"startup": {
"git": { "url": "https://github.com/acme/app.git", "ref": "main", "depth": 1 },
"env": { "NODE_ENV": "development" },
"secrets": ["OPENAI_API_KEY"],
"cache": { "package_managers": ["npm", "pnpm", "pip", "uv"] },
"commands": [
{ "name": "install", "run": "pnpm install --frozen-lockfile", "cache_key": "pnpm-lock.yaml" },
{ "name": "dev", "run": "pnpm dev --host 0.0.0.0", "background": true }
],
"ports": [3000, 6080],
"ready": { "http": "http://127.0.0.1:3000", "timeout_seconds": 30 },
"network": { "egress": "default" }
}
}Constrained knobs (spec §3.2) — arbitrary values like 13 GB or 250 GB are rejected with 400 bad_request:
| Knob | Allowed | Default |
|---|---|---|
cpu | 0.5, 1, 2, 4 | 1 |
memory_mb | 512, 1024, 2048, 4096, 8192, 16384 | 2048 |
disk_gb | 8, 16, 32, 64 | 8 |
image | base, node-python, browser, heavy-build, custom/… | base |
auto_stop_seconds | 30–3600 | 120 |
Create / get response
{
"id": "sbx_…",
"runtime": "firecracker",
"image": "base",
"state": "running",
"resources": { "cpu": "1 shared vCPU", "memory_mb": 2048, "disk_gb": 8 },
"node_id": "node_…",
"boot_path": "hot_pool", // hot_pool | snapshot_restore | cold_boot
"boot_ms": 42,
"browser_ready_ms": 1280, // present only for browser sandboxes
"coding_agent": "opencode", // present only when the coding agent is opted in
"auto_stop_seconds": 120,
"timings": { "boot_ms": 42, "image_cache_ms": 0, "git_ms": 0,
"install_ms": 0, "ready_ms": 0, "total_ms": 43 },
"urls": { "ports": { "3000": "https://sbx_…-3000.<domain>" },
"vnc": "https://sbx_…-6080.<domain>",
"cdp": "https://sbx_…-9222.<domain>" },
"price": { "resource_units": 1.0, "image_multiplier": 1.0,
"unit_price_usd_hr": 0.009, "price_usd_hr": 0.009,
"price_usd_second": 0.0000025 },
"uptime_seconds": 0,
"cost_estimate_usd": 0.0
}Templates
Templates are org-scoped, named sandbox create configs. Use them when a main agent or CI job needs to spawn many identical workdirs without resending the full image, resource, git, command, secret, network, file, and port config every time.
| Method | Path | Notes |
|---|---|---|
POST | /v1/templates | Create {name, description?, create}. |
GET | /v1/templates | List templates for the caller's org. |
GET | /v1/templates/:name | Get one template. |
PUT | /v1/templates/:name | Replace the template config. |
DELETE | /v1/templates/:name | Delete it. |
POST | /v1/templates/:name/sandboxes | Spawn one or many sandboxes with {count?, overrides?}. |
{
"name": "node-review",
"create": {
"image": "node-python",
"resources": { "cpu": 2, "memory_mb": 4096, "disk_gb": 16 },
"startup": {
"git": { "url": "https://github.com/acme/app.git", "ref": "main" },
"secrets": ["OPENAI_API_KEY"],
"commands": [{ "name": "install", "run": "npm ci" }]
}
}
}Background agent runs
POST /v1/agent-runs starts a headless Codex or Claude Code run in a sandbox. Workdir injects the provider key by secret name, tells the agent to edit files directly and never ask follow-up questions, collects the diff, runs optional verification, enforces constraints, and can open a draft GitHub PR from the control plane. GitHub write tokens are not injected into the sandbox.
| Method | Path | Notes |
|---|---|---|
POST | /v1/agent-runs | Queue a change or review run. |
GET | /v1/agent-runs | List runs with compact report fields. Filters: state, parent_run_id, label. |
GET | /v1/agent-runs/:id | Full run state with inline report. |
GET | /v1/agent-runs/:id/report | Backend-generated report for the delegating agent. |
GET | /v1/agent-runs/:id/logs | Raw stdout, stderr, and diff. |
GET | /v1/agent-runs/:id/children | Child runs for orchestration, with compact report fields. |
POST | /v1/agent-runs/:id/cancel | Cancel while queued or running. |
Request
{
"agent": "codex",
"model": "gpt-5-codex",
"prompt": "Add caching for the user lookup path and update tests.",
"api_key_secret": "OPENAI_API_KEY",
"template": "node-review",
"repo": { "url": "https://github.com/acme/app.git", "ref": "main" },
"hardness": "medium",
"task": {
"name": "Cache user lookup",
"external_id": "planner-42",
"parent_run_id": "arun_parent",
"labels": ["backend", "cache"],
"priority": 20
},
"mode": "change",
"constraints": {
"allowed_paths": ["src/", "tests/"],
"blocked_paths": [".github/"],
"max_changed_files": 8,
"max_diff_bytes": 50000,
"max_runtime_seconds": 1800
},
"verify": [
{ "name": "tests", "run": "npm test", "timeout_seconds": 300, "fail_run": true }
],
"github": { "token_secret": "GITHUB_TOKEN", "base_branch": "main", "draft": true }
}mode: "review" runs inspection only. It can succeed without a diff or PR and should return findings through stdout and small files under .workdir/artifacts/. In change mode, branches use workdir/<task-slug>-<run-id>; PR titles prefer task.name, and the PR body includes the backend report.
Report
The report is generated by Workdir, not by the agent. It includes task metadata, model, timing, sandbox id, outcome, deterministic summary, changed files, diff stats, constraint results, verification results, artifact metadata, PR URL, failure reason, and stdout/stderr tails. Main agents can poll with the SDK wait() helper and then reconcile child results with report() or children(). List and children responses include compact report summaries; fetch/report for the full authoritative handoff.
{
"id": "arun_...",
"state": "succeeded",
"outcome": "succeeded",
"summary": "Changed 3 file(s), verification passed, draft PR opened.",
"task": { "name": "Cache user lookup", "labels": ["backend", "cache"] },
"changed_files": [
{ "path": "src/users/cache.ts", "status": "modified", "additions": 42, "deletions": 7 }
],
"verification": [{ "name": "tests", "passed": true, "exit_code": 0 }],
"constraints": { "passed": true, "violations": [] },
"artifacts": [{ "path": ".workdir/artifacts/notes.md", "size_bytes": 812 }],
"pr_url": "https://github.com/acme/app/pull/123"
}Images
| Method | Path | Notes |
|---|---|---|
GET | /v1/images | Curated catalog + the org's custom images. |
POST | /v1/images | Build/import asynchronously (202 Accepted). |
GET | /v1/images/:id | Status + build_log + cache-miss time. |
DELETE | /v1/images/:id | Soft delete: blocks new creates, keeps running sandboxes. |
// POST /v1/images
{
"source": { "type": "dockerfile",
"context_url": "https://github.com/acme/app/archive/main.tar.gz",
"dockerfile": "Dockerfile" },
"name": "custom/acme/app",
"resources_hint": { "cpu": 2, "memory_mb": 4096, "disk_gb": 16 }
}
// or "source": { "type": "oci", "image_ref": "ghcr.io/acme/app:1.2.3" }Use a published custom image: {"image": "custom/acme/app", "image_version": "2026-06-10-ab12cd"}.
Secrets
Org-scoped, encrypted at rest, never returned over the API (see the FEATURES.md secret-management section).
| Method | Path | Notes |
|---|---|---|
GET | /v1/secrets | List secret names + timestamps (no values). |
PUT | /v1/secrets/:name | {value} → store/replace (encrypted). |
DELETE | /v1/secrets/:name | Remove a secret. |
Reference secrets in startup.secrets: ["NAME", ...]; values are injected into the sandbox env after assignment. A sandbox with resident secrets cannot be snapshotted (409).
Persistent volumes
Org-scoped block storage that survives sandbox deletion, so workspace state persists across sessions. A volume attaches to at most one running sandbox at a time.
| Method | Path | Notes |
|---|---|---|
GET | /v1/volumes | List the org's volumes. |
POST | /v1/volumes | {name, size_gb} → create. size_gb ∈ {1,5,10,20,50,100,250}. |
GET | /v1/volumes/:id | Get one (incl. attached_to). |
DELETE | /v1/volumes/:id | Delete + free storage; 409 while attached. |
Attach at sandbox-create with volumes: [{ "volume_id": "vol_…", "mount_path": "/mnt/data" }]. The volume is mounted (ext4) at mount_path in the guest; attaching forces a cold boot. Deleting the sandbox detaches the volume (data intact) so it can be re-attached to a new one.
Extended create options
Added to POST /v1/sandboxes (all optional, default-off):
{
"docker": { "enabled": true }, // dockerd inside the guest VM (heavy-build/custom image)
"coding_agent": { "enabled": true }, // install opencode CLI into the guest (opt-in; any image)
"mounts": [ { "type": "s3", "bucket": "my-data", "mount_path": "/mnt/data",
"read_only": true, "prefix": "p/", "region": "us-east-1" } ],
"files": [ { "path": "config.json", "content": "{}", "encoding": "utf8" } ]
}Custom images accept "ephemeral": true + "ttl_seconds": Nfor auto-GC’d one-off images. Full reference: FEATURES.md.
Nodes
| Method | Path | Notes |
|---|---|---|
GET | /v1/nodes | Nodes + capacity in default-equivalent units + add-node command. |
POST | /v1/nodes/join-token | Admin: mint/rotate a worker join token. |
POST | /v1/nodes/:id/drain | Admin: mark unschedulable + draining. |
Usage, billing, benchmarks
| Method | Path | Notes |
|---|---|---|
GET | /v1/usage | Org cost, delivered unit-seconds, prepaid balance, per-sandbox. |
GET | /v1/admin/overview | Admin: nodes, hot pools, reconciled at-cost price, abuse alerts. |
GET | /v1/benchmarks | Latency table: p50/p90/p95 by image and boot path (cold_boot/hot_pool/snapshot_restore/fork), reported separately and never merged. |
POST | /v1/benchmarks/run | Admin: run a fresh harness sweep {image?, iterations?} and return the recomputed table. |
GET | /healthz | Liveness (no auth). |
The benchmark harness (roadmap Phase 0) drives the runtime directly with throwaway VMs — not billable sandboxes — so the baseline measures the boot machinery honestly. snapshot_restore is the perpetual-standby resume path and carries the Phase 2 targets (p50 < 25ms, p90 < 50ms), surfaced under targets in the response.
Preview proxy
Host-routed: https://<sandbox-id>-<port>.<domain>/…. HTTP is forwarded; WebSocket/CDP/VNC upgrades are bridged. Requires a valid API key (header or ?key=) belonging to the sandbox’s org. A path-based form /_preview/<id>/<port>/<rest> exists for environments without wildcard DNS.
Driving the browser over CDP
For browser sandboxes the create/get response includes urls.cdp (https://<id>-9222.<domain>). It speaks the Chrome DevTools Protocol, so any CDP client — Playwright, Puppeteer, chrome-remote-interface — can drive the live Chrome.
Like every preview route it requires your API key (any key in the sandbox’s org, or admin), passed as a ?key= query param or an Authorization: Bearer header. An unauthenticated request returns 404 by design — existence is never leaked across orgs. The key= param is stripped before the request reaches Chrome and redacted from logs, so it is safe in the URL; prefer it for WebSocket clients that cannot set headers on the upgrade.
import { chromium } from "playwright";
// query-param auth — most portable; also covers the raw WebSocket upgrade
const browser = await chromium.connectOverCDP(`${cdpUrl}?key=${apiKey}`);
// or header auth
// const browser = await chromium.connectOverCDP(cdpUrl, {
// headers: { Authorization: `Bearer ${apiKey}` },
// });
const page = await browser.newPage();
await page.goto("https://example.com");Chrome advertises an internal webSocketDebuggerUrl (the guest IP); Playwright and Puppeteer rewrite it to the endpoint host automatically, so no extra config is needed. GET /json/version and /json/list work the same way for manual target discovery.
Error codes
| Error code | HTTP status |
|---|---|
bad_request | 400 |
unauthorized | 401 |
forbidden | 403 |
not_found | 404 |
conflict | 409 |
rejected | 422 |
no_capacity | 503 (with reason) |
internal | 500 |
no_capacity.reason is one of no_nodes, no_schedulable_nodes, no_browser_capable_node, memory_admission, remote_placement_unsupported.