mv37.org / docs

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 /v1 route. 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.

  1. Sign up at workdir.dev with an email and password (or GitHub, where enabled). This creates your org and signs you in.
  2. 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.
  3. Use it as Authorization: Bearer sk_live_... against https://api.workdir.dev on every /v1 request.
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

MethodPathNotes
POST/v1/sandboxesCreate. Empty body = default cheap path.
GET/v1/sandboxesList the caller's sandboxes.
GET/v1/sandboxes/:idGet one (with timings, urls, price, uptime, cost).
POST/v1/sandboxes/:id/exec{cmd, cwd?, env?, background?} {exit_code, stdout, stderr}.
GET/v1/sandboxes/:id/ptyWebSocket interactive shell (a real in-guest TTY over vsock on Firecracker).
GET/v1/sandboxes/:id/metricsWorking-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/browserBrowser readiness + VNC/CDP urls + screenshot url.
GET/v1/sandboxes/:id/browser/screenshotPNG of the live desktop (X root window).
POST/v1/sandboxes/:id/snapshotSnapshot (billed separately).
POST/v1/sandboxes/:id/forkClone an instant sibling from the parent’s live state (boot_path: "fork").
POST/v1/sandboxes/:id/pauseStop (release CPU/mem; keep billing correct).
POST/v1/sandboxes/:id/resumeResume from stopped disk/snapshot.
DELETE/v1/sandboxes/:idStop, delete ephemeral disk, remove routes.

Lifecycle & perpetual standby

States: creating → running → stopping → {stopped|standby} → resuming → running, plus deleting → deleted and failed.

  • stopped is a user-initiated pause (POST .../pause) and requires an explicit POST .../resume.
  • standby is automatic: when a sandbox is idle past auto_stop_seconds, the reaper snapshots it, frees its RAM, and parks it in standby at $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 plain stopped instead.)

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:

KnobAllowedDefault
cpu0.5, 1, 2, 41
memory_mb512, 1024, 2048, 4096, 8192, 163842048
disk_gb8, 16, 32, 648
imagebase, node-python, browser, heavy-build, custom/…base
auto_stop_seconds30–3600120

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.

MethodPathNotes
POST/v1/templatesCreate {name, description?, create}.
GET/v1/templatesList templates for the caller's org.
GET/v1/templates/:nameGet one template.
PUT/v1/templates/:nameReplace the template config.
DELETE/v1/templates/:nameDelete it.
POST/v1/templates/:name/sandboxesSpawn 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.

MethodPathNotes
POST/v1/agent-runsQueue a change or review run.
GET/v1/agent-runsList runs with compact report fields. Filters: state, parent_run_id, label.
GET/v1/agent-runs/:idFull run state with inline report.
GET/v1/agent-runs/:id/reportBackend-generated report for the delegating agent.
GET/v1/agent-runs/:id/logsRaw stdout, stderr, and diff.
GET/v1/agent-runs/:id/childrenChild runs for orchestration, with compact report fields.
POST/v1/agent-runs/:id/cancelCancel 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

MethodPathNotes
GET/v1/imagesCurated catalog + the org's custom images.
POST/v1/imagesBuild/import asynchronously (202 Accepted).
GET/v1/images/:idStatus + build_log + cache-miss time.
DELETE/v1/images/:idSoft 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).

MethodPathNotes
GET/v1/secretsList secret names + timestamps (no values).
PUT/v1/secrets/:name{value} → store/replace (encrypted).
DELETE/v1/secrets/:nameRemove 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.

MethodPathNotes
GET/v1/volumesList the org's volumes.
POST/v1/volumes{name, size_gb} → create. size_gb{1,5,10,20,50,100,250}.
GET/v1/volumes/:idGet one (incl. attached_to).
DELETE/v1/volumes/:idDelete + 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

MethodPathNotes
GET/v1/nodesNodes + capacity in default-equivalent units + add-node command.
POST/v1/nodes/join-tokenAdmin: mint/rotate a worker join token.
POST/v1/nodes/:id/drainAdmin: mark unschedulable + draining.

Usage, billing, benchmarks

MethodPathNotes
GET/v1/usageOrg cost, delivered unit-seconds, prepaid balance, per-sandbox.
GET/v1/admin/overviewAdmin: nodes, hot pools, reconciled at-cost price, abuse alerts.
GET/v1/benchmarksLatency table: p50/p90/p95 by image and boot path (cold_boot/hot_pool/snapshot_restore/fork), reported separately and never merged.
POST/v1/benchmarks/runAdmin: run a fresh harness sweep {image?, iterations?} and return the recomputed table.
GET/healthzLiveness (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 codeHTTP status
bad_request400
unauthorized401
forbidden403
not_found404
conflict409
rejected422
no_capacity503 (with reason)
internal500

no_capacity.reason is one of no_nodes, no_schedulable_nodes, no_browser_capable_node, memory_admission, remote_placement_unsupported.