Skip to content

API Reference

The bhatti server exposes a REST API over HTTP. Every authenticated endpoint requires a Bearer token; unauthenticated endpoints are limited to /health and /_shell/<id> (the embedded web-shell page).

This page is the canonical hand-curated reference. Each endpoint shows a runnable curl and the response shape. Anything not in the curl example uses the documented default.

ModeAPI basePublic proxy
Default (listen: :8080)http://localhost:8080path-based at /sandboxes/<id>/proxy/<port>/
Path-based public proxy (public_proxy_listen: :8443)http://localhost:8080http://<host>:8443/<alias>/
Domain mode (domain.api_host, domain.proxy_zone)https://api.<your-domain>https://<alias>.<your-domain>

All endpoints except /health and /_shell/... require a Bearer token in the Authorization header:

Authorization: Bearer bht_abc123def456...

Tokens come from bhatti user create. The CLI loads them from ~/.bhatti/config.yaml.

WebSocket endpoints use the same Bearer header. There is no ?token=… query-param auth — leaving the token out of URLs eliminates accidental logging in proxy access logs.

The web shell is the exception: the URL contains #token=… in the fragment (never sent to the server), and the embedded page extracts the token client-side and uses it as a Bearer header on the WebSocket connection.

JSON for every endpoint. Errors use {"error": "...", "request_id": "..."}:

{"error": "sandbox not found", "request_id": "req_a1b2c3d4"}

request_id is included on every error response; quote it when reporting bugs.

CodeMeaning
200OK.
201Created. Used for resource-creation endpoints.
202Accepted. Used for async tasks (image pull) — body contains a task_id.
204No content. Used by unpublish.
400Validation error. Body has {"error": "..."}.
401Missing or invalid Bearer token.
403Forbidden — typically a per-sandbox cap (CPU, memory) being exceeded.
404Resource not found.
409Conflict — name already exists, or volume is attached when an op needs it detached.
413Request too large (file write > 100 MB).
429Rate-limited or quota exceeded (max-sandboxes, max-cpus, etc).
500Internal server error.
501Endpoint exists but the server isn’t configured for it (e.g. backups without an s3_* config).
502Bad gateway from the reverse proxy — the sandbox process isn’t listening on the requested port.

The server stamps every response with:

  • X-Bhatti-Version — server version. CLI clients use this to detect server upgrades.
  • X-Bhatti-Min-CLI — the minimum CLI version the server requires. Clients older than this should upgrade.
  • X-Bhatti-Existing: true — set on POST /sandboxes when the request was idempotent (a sandbox with that name already existed).

Long-running operations like POST /images/pull return immediately with 202 Accepted and a body like:

{"task_id": "tsk_abc123", "status": "running"}

Poll GET /tasks/:id until status is completed or failed. The progress field, when present, contains a human-readable progress string the CLI can display.


GET /health

No auth required. Lightweight check.

Terminal window
curl http://localhost:8080/health
{"status": "ok", "uptime": "2h15m30s"}

GET /sandboxes

Returns sandboxes owned by the authenticated user, enriched with thermal state and any published URLs. Listing does not wake cold sandboxes.

Terminal window
curl http://localhost:8080/sandboxes \
-H "Authorization: Bearer $TOKEN"
[
{
"id": "a1b2c3d4",
"name": "dev",
"status": "running",
"thermal": "hot",
"ip": "192.168.137.2",
"cpus": 2,
"memory_mb": 1024,
"image": "minimal",
"created_at": "2026-04-29T15:21:48Z",
"urls": ["https://dev-k3m9x2.bhatti.sh"]
}
]
POST /sandboxes
Terminal window
curl -X POST http://localhost:8080/sandboxes \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "dev",
"cpus": 2,
"memory_mb": 1024,
"env": {"NODE_ENV": "production"},
"init": "cd /workspace && npm install",
"image": "minimal",
"persistent_volumes": [
{"name": "workspace", "mount": "/workspace", "auto_create": false}
],
"secrets": ["OPENAI_KEY"],
"files": [
{"guest_path": "/etc/app/config.json", "content": "<base64>", "mode": "0644"}
]
}'

Request fields:

FieldTypeDefaultNotes
namestringautoPattern: [a-zA-Z0-9][a-zA-Z0-9._-]{0,62}.
cpusfloat1Fractional values allowed. Capped at user’s max_cpus_per_sandbox.
memory_mbint1024Capped at user’s max_memory_mb_per_sandbox.
disk_size_mbintimage sizeResize the rootfs at create time.
envobjectEnv vars baked into the config drive.
initstringBoot-time script. Runs as a session named init.
keep_hotboolfalseDisables the thermal manager for this sandbox.
hugepagesboolfalse2 MB hugepages. Faster boot, no diff snapshots.
imagestringminimalImage name from GET /images.
template_idstringCreate from a template.
persistent_volumesarray[{name, mount, auto_create, read_only, size_mb}].
secretsarrayNames of stored secrets to inject as env vars. Ignored when template_id is set.
filesarray[{guest_path, content (base64), mode}]. Ignored when template_id is set.
new_volumesarrayLegacy equivalent of persistent_volumes. The CLI no longer sends these.
volumesarrayLegacy equivalent.

Responses:

  • 201 Created with the sandbox object.
  • 200 OK + X-Bhatti-Existing: true if a non-destroyed sandbox with that name already exists. Idempotent — safe to retry from scripts.
  • 400 for invalid name / size / etc.
  • 403 if cpus or memory_mb exceed the user’s per-sandbox cap.
  • 429 if the user is at their max-sandboxes cap.
GET /sandboxes/:id
Terminal window
curl http://localhost:8080/sandboxes/dev \
-H "Authorization: Bearer $TOKEN"

Returns the full sandbox record. Both name and ID work as :id.

PATCH /sandboxes/:id

Currently only keep_hot is mutable. All other fields (cpus, memory, image) are immutable after creation — destroy and recreate.

Terminal window
curl -X PATCH http://localhost:8080/sandboxes/dev \
-H "Authorization: Bearer $TOKEN" \
-d '{"keep_hot": true}'

Setting keep_hot: true on a stopped sandbox immediately wakes it.

DELETE /sandboxes/:id
Terminal window
curl -X DELETE http://localhost:8080/sandboxes/dev \
-H "Authorization: Bearer $TOKEN"
{"status": "destroyed"}

Persistent volumes attached to the sandbox are detached (not deleted). Published URLs are cleaned up.

POST /sandboxes/:id/stop

Snapshot to disk and free memory. Resume with POST /sandboxes/:id/start. The first stop creates a full snapshot; subsequent stops create diff snapshots (dirty pages only).

Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/stop \
-H "Authorization: Bearer $TOKEN"
POST /sandboxes/:id/start

Restore from snapshot.

Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/start \
-H "Authorization: Bearer $TOKEN" \
-d '{"force": true}'

force: true retries even if the sandbox is in unknown state from a previously failed restore.


POST /sandboxes/:id/exec
Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/exec \
-H "Authorization: Bearer $TOKEN" \
-d '{"cmd": ["echo", "hello"]}'
{"exit_code": 0, "stdout": "hello\n", "stderr": ""}

Request fields:

FieldTypeDefaultNotes
cmdarray(required)Command and arguments.
timeout_secint300Max 86400 (24h).
detachboolfalseFire-and-forget. Returns {pid, output_file, detached: true} immediately.
output_filestringautoWhen detach: true, the guest path to redirect output to.

This endpoint does not accept an env field. Env vars are baked into the sandbox’s config drive at creation time (--env, --secret). To pass per-call env vars, use the WebSocket exec path below.

Set Accept: application/x-ndjson:

Terminal window
curl -N -X POST http://localhost:8080/sandboxes/dev/exec \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/x-ndjson" \
-d '{"cmd":["npm","install"]}'
{"type":"stdout","data":"Installing dependencies...\n"}
{"type":"stderr","data":"npm warn deprecated...\n"}
{"type":"exit","exit_code":0}

Each line is flushed immediately. type is stdout, stderr, exit, or error.

Cold sandboxes wake automatically before executing.

GET /sandboxes/:id/ws

Upgrade to WebSocket. Authentication via Authorization: Bearer ... header on the upgrade request.

Query paramDescription
session=<id>Reattach to an existing session by ID.
new=trueForce a new session even if one is currently attached.

Wire protocol:

  • Server → client (binary): raw terminal output.
  • Server → client (text): initial {"type":"session","session_id":"s1"} message; thereafter only on session events.
  • Client → server (binary): keystrokes (forwarded to the PTY).
  • Client → server (text JSON): {"type":"resize","rows":N,"cols":N} or {"cmd":[...], "env":{}, "max_idle_sec":N} (first message of a non-attached session — this is where per-call env injection lives).

Detach by closing the connection. The session keeps running.

GET /sandboxes/:id/sessions
Terminal window
curl http://localhost:8080/sandboxes/dev/sessions \
-H "Authorization: Bearer $TOKEN"
[
{"session_id": "init", "argv": "npm install", "running": true, "attached": false, "created_at": 1761792000},
{"session_id": "s1", "argv": "/bin/zsh -li", "running": true, "attached": true, "created_at": 1761792100}
]
GET /sandboxes/:id/ports
Terminal window
curl http://localhost:8080/sandboxes/dev/ports \
-H "Authorization: Bearer $TOKEN"
[
{"sandbox_id": "a1b2c3d4", "container_port": 3000, "proxy_url": "/sandboxes/a1b2c3d4/proxy/3000/"}
]

The proxy_url is path-relative; prepend the API base URL for a full reachable URL.

GET /ports

Same row shape as above; covers every sandbox the authenticated user owns.


All file operations target the same path with query parameters.

GET /sandboxes/:id/files?path=/path

Returns raw file content with Content-Type: application/octet-stream. The X-File-Size header always carries the total size (so clients can detect truncation).

Terminal window
curl "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js" \
-H "Authorization: Bearer $TOKEN" \
-o app.js

Server-side truncation:

Terminal window
curl "http://localhost:8080/sandboxes/dev/files?path=/var/log/agent.log&offset=1&limit=2000&max_bytes=51200" \
-H "Authorization: Bearer $TOKEN"
ParamTypeDescription
pathstringAbsolute path inside the sandbox.
offsetint1-indexed line number to start from.
limitintMax lines to return.
max_bytesintMax bytes to return.

Whichever limit hits first stops the read.

GET /sandboxes/:id/files?path=/dir&ls=true
Terminal window
curl "http://localhost:8080/sandboxes/dev/files?path=/workspace&ls=true" \
-H "Authorization: Bearer $TOKEN"
[
{"name": "app.js", "size": 1234, "mode": "0644", "is_dir": false, "mtime": 1761792345},
{"name": "node_modules", "size": 4096, "mode": "0755", "is_dir": true, "mtime": 1761792500}
]

Capped at 10 000 entries; a sentinel row indicates truncation if hit.

HEAD /sandboxes/:id/files?path=/path
Terminal window
curl -I "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js" \
-H "Authorization: Bearer $TOKEN"

Response headers:

HeaderDescription
X-File-SizeSize in bytes.
X-File-ModeOctal mode, e.g. 0644.
X-File-IsDirtrue or false.
PUT /sandboxes/:id/files?path=/path[&mode=0644]

Body is the raw content. Content-Length is required (chunked transfer-encoding is rejected).

Terminal window
curl -X PUT "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js&mode=0644" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Length: 25" \
--data-binary 'console.log("hello world")'
{"status": "ok"}

Writes are atomic (temp file + fsync + rename). 100 MB hard cap.


ANY /sandboxes/:id/proxy/:port/*path

Tunnels HTTP and WebSocket through the engine into the sandbox. The path after /proxy/<port>/ is forwarded verbatim to localhost:<port> inside the VM. Bearer auth required.

Useful for development. For public, auth-free URLs, use publish.

Terminal window
curl http://localhost:8080/sandboxes/dev/proxy/3000/api/users \
-H "Authorization: Bearer $TOKEN"

502 is returned if no process is listening on the port.

POST /sandboxes/:id/publish
Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/publish \
-H "Authorization: Bearer $TOKEN" \
-d '{"port": 3000, "alias": "my-app"}'
{
"id": "pub_a1b2c3d4",
"sandbox_id": "a1b2c3d4",
"port": 3000,
"alias": "my-app",
"url": "https://my-app.bhatti.sh",
"created_at": "2026-04-30T17:00:00Z"
}

If alias is omitted, an alias is auto-generated as <sandbox-name>-<6-hex>. 409 is returned on alias collision.

The url value depends on the server config:

Server configURL format
domain.proxy_zone: bhatti.shhttps://my-app.bhatti.sh
public_proxy_listen: :8443 onlyhttp://<host>:8443/my-app/
Neither(no public proxy configured) alias: my-app — the CLI surfaces a hint to use the proxy URL.
GET /sandboxes/:id/publish

Returns an array of publish rules with their generated URLs.

DELETE /sandboxes/:id/publish/:port
Terminal window
curl -X DELETE http://localhost:8080/sandboxes/dev/publish/3000 \
-H "Authorization: Bearer $TOKEN"

204 No Content.

POST /sandboxes/:id/shell-token

Generates a new web-shell token bound to this sandbox. Each call invalidates the previous token — only one is active at a time.

Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/shell-token \
-H "Authorization: Bearer $TOKEN"
{
"url": "https://api.bhatti.sh/_shell/a1b2c3d4#token=k3m9x2qr...",
"token": "k3m9x2qr..."
}

The token is in the URL fragment (#token=...); fragments are not sent to the server. The _shell page extracts the token client-side and uses it as a Bearer header on the WebSocket.

DELETE /sandboxes/:id/shell-token

The previously-active token (if any) is invalidated immediately.


Templates are reusable sandbox blueprints. The CLI doesn’t expose them yet; reach for the API directly.

GET /templates
POST /templates
GET /templates/:id
DELETE /templates/:id
Terminal window
curl -X POST http://localhost:8080/templates \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "node-dev",
"cpus": 2,
"memory_mb": 1024,
"image": "minimal",
"env": {"NODE_ENV": "development"},
"user_data": "cd /workspace && npm install",
"secrets": ["OPENAI_KEY"]
}'

Use template_id in POST /sandboxes to create a sandbox from a template. Template fields are defaults; per-request fields override them. Note: the template path silently drops request-side secrets and files arrays — set them on the template instead, or don’t use a template.


Encrypted at rest with age, scoped per user.

GET /secrets

Returns an array of {name, created_at, updated_at}. Values are never returned.

POST /secrets
Terminal window
curl -X POST http://localhost:8080/secrets \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "OPENAI_KEY", "value": "sk-..."}'

Re-posting with an existing name updates the value silently.

DELETE /secrets/:name
Terminal window
curl -X DELETE http://localhost:8080/secrets/OPENAI_KEY \
-H "Authorization: Bearer $TOKEN"

Persistent ext4 volumes. Several operations require the volume to be detached (no active sandbox attachments) and return 409 otherwise: delete, resize, clone (snapshot), restore.

GET /volumes

Returns an array of volume records including current attachments.

POST /volumes
Terminal window
curl -X POST http://localhost:8080/volumes \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "workspace", "size_mb": 5120}'

429 if the user is over their storage quota.

GET /volumes/:name
DELETE /volumes/:name

409 if attached.

POST /volumes/:name/resize
Terminal window
curl -X POST http://localhost:8080/volumes/workspace/resize \
-H "Authorization: Bearer $TOKEN" \
-d '{"size_mb": 10240}'

new_size > current_size only — shrinking is not supported. 409 if attached.

POST /volumes/:name/snapshot

Independent point-in-time copy. Source must be detached.

Terminal window
curl -X POST http://localhost:8080/volumes/workspace/snapshot \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "workspace-pre-upgrade"}'

Volume backups to S3-compatible storage. Requires a backup block in the server config; otherwise every endpoint returns 501.

GET /volumes/:name/backups
POST /volumes/:name/backups

Compresses (zstd) and uploads. Does not require the volume to be detached, but writes in flight at backup time may produce an inconsistent snapshot — stop the sandbox first if consistency matters.

POST /volumes/:name/backups/restore
Terminal window
curl -X POST http://localhost:8080/volumes/workspace/backups/restore \
-H "Authorization: Bearer $TOKEN" \
-d '{"backup_id": "bk_a1b2c3d4"}'

409 if attached.

DELETE /volumes/:name/backups/:backup_id

GET /images

Returns user-owned, system tier, and shared images.

GET /images/:name
DELETE /images/:name

System tier images can’t be deleted via this endpoint.

POST /images/pull

Async — returns 202 with a task ID. Poll /tasks/<id> until it completes.

Terminal window
curl -X POST http://localhost:8080/images/pull \
-H "Authorization: Bearer $TOKEN" \
-d '{"ref": "python:3.12", "name": "python-3.12"}'
{"task_id": "tsk_abc123", "status": "running"}

Optional auth: "user:token" for simple HTTP basic auth. For real private registries with rotating credentials, prefer image import after a local docker pull.

If the same image was already pulled with the same content digest, returns 200 immediately with the existing record.

POST /images/import?name=<name>

Body is a docker save-style tarball streamed as Content-Type: application/x-tar. The CLI uses this for bhatti image import.

Terminal window
docker save python:3.12 | curl -X POST \
"http://localhost:8080/images/import?name=python-3.12" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/x-tar" \
--data-binary @-
{"name": "python-3.12", "size_mb": 284}
POST /sandboxes/:id/save-image
Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/save-image \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "my-stack"}'

The sandbox keeps running. Only the rootfs is captured; persistent volumes are not part of the image.


Named VM snapshots — memory + CPU + disk + processes.

GET /snapshots
GET /snapshots/:name
DELETE /snapshots/:name
POST /sandboxes/:id/checkpoint
Terminal window
curl -X POST http://localhost:8080/sandboxes/dev/checkpoint \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "pre-experiment"}'

The source sandbox keeps running.

POST /snapshots/:name/resume

Creates a new sandbox from the snapshot.

Terminal window
curl -X POST http://localhost:8080/snapshots/pre-experiment/resume \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "dev-restored"}'

name is optional — auto-generated from the source sandbox name if omitted. Persistent volumes are not re-attached.


Async operations get a task ID; poll its status here.

GET /tasks/:id
{
"id": "tsk_abc123",
"status": "running",
"progress": "downloading layers (45 MB / 142 MB)",
"error": "",
"result": ""
}

status is running, completed, or failed.

DELETE /tasks/:id

Best-effort cancel. Some operations are uncancellable past a certain point (e.g. a finalizing image conversion).


There’s no HTTP API for image sharing. It’s a local SQLite operation done with bhatti image share --user <name> on the server itself, using --data-dir to point at the data directory. This is an intentional restriction — image visibility is controlled at the database level, not via the API.