All endpoints require Authorization: Bearer <token> except /health and static paths (/, /index.html, /static/*).
Base URL: http://localhost:8080 (configurable via listen in config.yaml)
GET /health
No auth required. Returns status, sandbox count, and uptime.
{"status": "ok", "sandboxes": 3, "uptime": "2h15m30s"}POST /sandboxes
Direct creation (no template):
{
"name": "dev",
"cpus": 2,
"memory_mb": 1024,
"env": {"API_KEY": "sk-..."},
"init": "cd /workspace && npm install",
"keep_hot": false,
"new_volumes": [{"name": "work", "size_mb": 256, "mount": "/workspace"}]
}All fields optional. Defaults: 1 CPU, 512MB RAM, auto-generated name.
keep_hot prevents the thermal manager from pausing or snapshotting this sandbox. Use for autonomous agents that maintain persistent external connections (e.g. Slack WebSocket). Can be toggled on existing sandboxes via PATCH.
Template-based creation:
{
"template_id": "tmpl-abc123",
"name": "dev",
"env": {"API_KEY": "sk-..."}
}Response (201):
{
"id": "a1b2c3d4",
"name": "dev",
"status": "running",
"ip": "192.168.137.2",
"created_at": "2026-03-22T10:00:00Z"
}GET /sandboxes
Response: array of sandbox objects.
GET /sandboxes/:id
PATCH /sandboxes/:id
{"keep_hot": true}Toggles mutable sandbox properties. Currently supports keep_hot only. Returns the updated sandbox object.
DELETE /sandboxes/:id
POST /sandboxes/:id/stop
Snapshots the VM to disk. First stop creates a full snapshot; subsequent stops create diff snapshots (dirty pages only). Returns the updated sandbox object.
POST /sandboxes/:id/start
Restores from snapshot. Returns the updated sandbox object.
POST /sandboxes/:id/exec
{"cmd": ["echo", "hello"]}Buffered response (default):
{"exit_code": 0, "stdout": "hello\n", "stderr": ""}Streaming response (with Accept: application/x-ndjson):
curl -N -H "Accept: application/x-ndjson" \
-H "Authorization: Bearer $TOKEN" \
-d '{"cmd":["npm","install"]}' \
http://localhost:8080/sandboxes/$ID/exec{"type":"stdout","data":"Installing dependencies...\n"}
{"type":"stderr","data":"npm warn deprecated ...\n"}
{"type":"stdout","data":"added 847 packages in 12s\n"}
{"type":"exit","exit_code":0}Each line is flushed immediately. Useful for long-running commands where the consumer wants real-time output.
GET /sandboxes/:id/ws
Upgrade to WebSocket. Auth via query param (?token=...) or Authorization header.
Terminal → WebSocket: binary messages containing raw terminal output.
WebSocket → Terminal: binary messages forwarded as keystrokes. Text messages with JSON {"type":"resize","rows":N,"cols":N} trigger terminal resize.
GET /sandboxes/:id/files?path=/workspace/app.js
Returns raw file content with Content-Type: application/octet-stream.
Headers:
Content-Length— file size (omitted for truncated reads)X-File-Size— total file size (always present, lets client detect truncation)
Server-side truncation:
GET /sandboxes/:id/files?path=/app.log&offset=1&limit=2000&max_bytes=51200
offset— 1-indexed line number to start fromlimit— max lines to returnmax_bytes— max bytes to return
Whichever limit hits first stops the read.
PUT /sandboxes/:id/files?path=/workspace/app.js
Body: raw file content. Content-Length header required (rejects chunked/unknown).
Optional query: mode=0644 (default: 0644).
Writes are atomic (temp file + rename). Concurrent readers never see partial content.
HEAD /sandboxes/:id/files?path=/workspace/app.js
Response headers:
X-File-Size— file size in bytesX-File-Mode— permissions (e.g.,0644)X-File-IsDir—trueorfalse
GET /sandboxes/:id/files?path=/workspace&ls=true
[
{"name": "app.js", "size": 1234, "mode": "0644", "is_dir": false, "mtime": 1711100000},
{"name": "node_modules", "size": 4096, "mode": "0755", "is_dir": true, "mtime": 1711100000}
]Capped at 10,000 entries. If truncated, a sentinel entry indicates the total count.
GET /sandboxes/:id/sessions
[
{"session_id": "init", "argv": "npm install", "tty": true, "running": true, "attached": false, "created_at": 1711100000},
{"session_id": "s1", "argv": "/bin/zsh -li", "tty": true, "running": true, "attached": true, "created_at": 1711100100}
]GET /sandboxes/:id/ports
[
{"container_port": 3000, "proxy_url": "/sandboxes/abc123/proxy/3000/", "host_port": 49152}
]GET /ports
All listening ports across all sandboxes.
ANY /sandboxes/:id/proxy/:port/*path
HTTP requests and WebSocket connections are tunneled through the engine into the sandbox. The request is rewritten to target localhost:<port> inside the VM.
POST /sandboxes/:id/publish
{"port": 3000, "alias": "my-app"}alias is optional. If omitted, an alias is auto-generated from the sandbox name with a random suffix (e.g. dev-k3m9x2).
Response (201):
{
"id": "pub_a1b2c3d4",
"sandbox_id": "a1b2c3d4",
"port": 3000,
"alias": "my-app",
"url": "https://my-app.bhatti.sh",
"created_at": "2026-03-30T17:00:00Z"
}The URL is publicly accessible without authentication. The sandbox wakes automatically from any thermal state when a request arrives.
GET /sandboxes/:id/publish
Response: array of publish rules with URLs.
DELETE /sandboxes/:id/publish/:port
Response: 204 No Content.
Publish rules are automatically cleaned up when a sandbox is destroyed.
POST /templates Create template
GET /templates List templates
GET /templates/:id Get template
DELETE /templates/:id Delete template
POST /secrets Create/update secret {"name": "...", "value": "..."}
GET /secrets List secrets (names only, no values)
DELETE /secrets/:name Delete secret
POST /volumes Create volume {"name": "..."}
GET /volumes List volumes
GET /volumes/:name Get volume
DELETE /volumes/:name Delete volume (fails if in use)