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.
Base URL
Section titled “Base URL”| Mode | API base | Public proxy |
|---|---|---|
Default (listen: :8080) | http://localhost:8080 | path-based at /sandboxes/<id>/proxy/<port>/ |
Path-based public proxy (public_proxy_listen: :8443) | http://localhost:8080 | http://<host>:8443/<alias>/ |
Domain mode (domain.api_host, domain.proxy_zone) | https://api.<your-domain> | https://<alias>.<your-domain> |
Authentication
Section titled “Authentication”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.
Response shape
Section titled “Response shape”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.
Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
200 | OK. |
201 | Created. Used for resource-creation endpoints. |
202 | Accepted. Used for async tasks (image pull) — body contains a task_id. |
204 | No content. Used by unpublish. |
400 | Validation error. Body has {"error": "..."}. |
401 | Missing or invalid Bearer token. |
403 | Forbidden — typically a per-sandbox cap (CPU, memory) being exceeded. |
404 | Resource not found. |
409 | Conflict — name already exists, or volume is attached when an op needs it detached. |
413 | Request too large (file write > 100 MB). |
429 | Rate-limited or quota exceeded (max-sandboxes, max-cpus, etc). |
500 | Internal server error. |
501 | Endpoint exists but the server isn’t configured for it (e.g. backups without an s3_* config). |
502 | Bad gateway from the reverse proxy — the sandbox process isn’t listening on the requested port. |
Response headers
Section titled “Response headers”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 onPOST /sandboxeswhen the request was idempotent (a sandbox with that name already existed).
Async tasks
Section titled “Async tasks”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.
System
Section titled “System”Health check
Section titled “Health check”GET /healthNo auth required. Lightweight check.
curl http://localhost:8080/health{"status": "ok", "uptime": "2h15m30s"}Sandboxes
Section titled “Sandboxes”List sandboxes
Section titled “List sandboxes”GET /sandboxesReturns sandboxes owned by the authenticated user, enriched with thermal state and any published URLs. Listing does not wake cold sandboxes.
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"] }]Create a sandbox
Section titled “Create a sandbox”POST /sandboxescurl -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:
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | auto | Pattern: [a-zA-Z0-9][a-zA-Z0-9._-]{0,62}. |
cpus | float | 1 | Fractional values allowed. Capped at user’s max_cpus_per_sandbox. |
memory_mb | int | 1024 | Capped at user’s max_memory_mb_per_sandbox. |
disk_size_mb | int | image size | Resize the rootfs at create time. |
env | object | — | Env vars baked into the config drive. |
init | string | — | Boot-time script. Runs as a session named init. |
keep_hot | bool | false | Disables the thermal manager for this sandbox. |
hugepages | bool | false | 2 MB hugepages. Faster boot, no diff snapshots. |
image | string | minimal | Image name from GET /images. |
template_id | string | — | Create from a template. |
persistent_volumes | array | — | [{name, mount, auto_create, read_only, size_mb}]. |
secrets | array | — | Names of stored secrets to inject as env vars. Ignored when template_id is set. |
files | array | — | [{guest_path, content (base64), mode}]. Ignored when template_id is set. |
new_volumes | array | — | Legacy equivalent of persistent_volumes. The CLI no longer sends these. |
volumes | array | — | Legacy equivalent. |
Responses:
201 Createdwith the sandbox object.200 OK+X-Bhatti-Existing: trueif a non-destroyed sandbox with that name already exists. Idempotent — safe to retry from scripts.400for invalid name / size / etc.403ifcpusormemory_mbexceed the user’s per-sandbox cap.429if the user is at theirmax-sandboxescap.
Get a sandbox
Section titled “Get a sandbox”GET /sandboxes/:idcurl http://localhost:8080/sandboxes/dev \ -H "Authorization: Bearer $TOKEN"Returns the full sandbox record. Both name and ID work as :id.
Update a sandbox
Section titled “Update a sandbox”PATCH /sandboxes/:idCurrently only keep_hot is mutable. All other fields (cpus, memory, image) are immutable after creation — destroy and recreate.
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.
Destroy a sandbox
Section titled “Destroy a sandbox”DELETE /sandboxes/:idcurl -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.
Stop a sandbox
Section titled “Stop a sandbox”POST /sandboxes/:id/stopSnapshot 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).
curl -X POST http://localhost:8080/sandboxes/dev/stop \ -H "Authorization: Bearer $TOKEN"Start a sandbox
Section titled “Start a sandbox”POST /sandboxes/:id/startRestore from snapshot.
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.
Execution & shells
Section titled “Execution & shells”Run a command
Section titled “Run a command”POST /sandboxes/:id/execcurl -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:
| Field | Type | Default | Notes |
|---|---|---|---|
cmd | array | (required) | Command and arguments. |
timeout_sec | int | 300 | Max 86400 (24h). |
detach | bool | false | Fire-and-forget. Returns {pid, output_file, detached: true} immediately. |
output_file | string | auto | When 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.
Streaming output
Section titled “Streaming output”Set Accept: application/x-ndjson:
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.
Interactive shell (WebSocket)
Section titled “Interactive shell (WebSocket)”GET /sandboxes/:id/wsUpgrade to WebSocket. Authentication via Authorization: Bearer ... header on the upgrade request.
| Query param | Description |
|---|---|
session=<id> | Reattach to an existing session by ID. |
new=true | Force 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.
List sessions
Section titled “List sessions”GET /sandboxes/:id/sessionscurl 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}]List listening ports (one sandbox)
Section titled “List listening ports (one sandbox)”GET /sandboxes/:id/portscurl 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.
List listening ports (all sandboxes)
Section titled “List listening ports (all sandboxes)”GET /portsSame row shape as above; covers every sandbox the authenticated user owns.
All file operations target the same path with query parameters.
Read a file
Section titled “Read a file”GET /sandboxes/:id/files?path=/pathReturns raw file content with Content-Type: application/octet-stream. The X-File-Size header always carries the total size (so clients can detect truncation).
curl "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js" \ -H "Authorization: Bearer $TOKEN" \ -o app.jsServer-side truncation:
curl "http://localhost:8080/sandboxes/dev/files?path=/var/log/agent.log&offset=1&limit=2000&max_bytes=51200" \ -H "Authorization: Bearer $TOKEN"| Param | Type | Description |
|---|---|---|
path | string | Absolute path inside the sandbox. |
offset | int | 1-indexed line number to start from. |
limit | int | Max lines to return. |
max_bytes | int | Max bytes to return. |
Whichever limit hits first stops the read.
List a directory
Section titled “List a directory”GET /sandboxes/:id/files?path=/dir&ls=truecurl "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.
Stat a file
Section titled “Stat a file”HEAD /sandboxes/:id/files?path=/pathcurl -I "http://localhost:8080/sandboxes/dev/files?path=/workspace/app.js" \ -H "Authorization: Bearer $TOKEN"Response headers:
| Header | Description |
|---|---|
X-File-Size | Size in bytes. |
X-File-Mode | Octal mode, e.g. 0644. |
X-File-IsDir | true or false. |
Write a file
Section titled “Write a file”PUT /sandboxes/:id/files?path=/path[&mode=0644]Body is the raw content. Content-Length is required (chunked transfer-encoding is rejected).
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.
Networking
Section titled “Networking”Reverse proxy
Section titled “Reverse proxy”ANY /sandboxes/:id/proxy/:port/*pathTunnels 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.
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.
Publish a port
Section titled “Publish a port”POST /sandboxes/:id/publishcurl -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 config | URL format |
|---|---|
domain.proxy_zone: bhatti.sh | https://my-app.bhatti.sh |
public_proxy_listen: :8443 only | http://<host>:8443/my-app/ |
| Neither | (no public proxy configured) alias: my-app — the CLI surfaces a hint to use the proxy URL. |
List published ports
Section titled “List published ports”GET /sandboxes/:id/publishReturns an array of publish rules with their generated URLs.
Unpublish a port
Section titled “Unpublish a port”DELETE /sandboxes/:id/publish/:portcurl -X DELETE http://localhost:8080/sandboxes/dev/publish/3000 \ -H "Authorization: Bearer $TOKEN"204 No Content.
Mint a shell token
Section titled “Mint a shell token”POST /sandboxes/:id/shell-tokenGenerates a new web-shell token bound to this sandbox. Each call invalidates the previous token — only one is active at a time.
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.
Revoke a shell token
Section titled “Revoke a shell token”DELETE /sandboxes/:id/shell-tokenThe previously-active token (if any) is invalidated immediately.
Templates
Section titled “Templates”Templates are reusable sandbox blueprints. The CLI doesn’t expose them yet; reach for the API directly.
GET /templatesPOST /templatesGET /templates/:idDELETE /templates/:idcurl -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.
Secrets
Section titled “Secrets”Encrypted at rest with age, scoped per user.
List secrets
Section titled “List secrets”GET /secretsReturns an array of {name, created_at, updated_at}. Values are never returned.
Create or update a secret
Section titled “Create or update a secret”POST /secretscurl -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 a secret
Section titled “Delete a secret”DELETE /secrets/:namecurl -X DELETE http://localhost:8080/secrets/OPENAI_KEY \ -H "Authorization: Bearer $TOKEN"Volumes
Section titled “Volumes”Persistent ext4 volumes. Several operations require the volume to be detached (no active sandbox attachments) and return 409 otherwise: delete, resize, clone (snapshot), restore.
List volumes
Section titled “List volumes”GET /volumesReturns an array of volume records including current attachments.
Create a volume
Section titled “Create a volume”POST /volumescurl -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 a volume
Section titled “Get a volume”GET /volumes/:nameDelete a volume
Section titled “Delete a volume”DELETE /volumes/:name409 if attached.
Resize a volume
Section titled “Resize a volume”POST /volumes/:name/resizecurl -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.
Clone a volume
Section titled “Clone a volume”POST /volumes/:name/snapshotIndependent point-in-time copy. Source must be detached.
curl -X POST http://localhost:8080/volumes/workspace/snapshot \ -H "Authorization: Bearer $TOKEN" \ -d '{"name": "workspace-pre-upgrade"}'Backups
Section titled “Backups”Volume backups to S3-compatible storage. Requires a backup block in the server config; otherwise every endpoint returns 501.
List backups for a volume
Section titled “List backups for a volume”GET /volumes/:name/backupsTrigger a backup
Section titled “Trigger a backup”POST /volumes/:name/backupsCompresses (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.
Restore from a backup
Section titled “Restore from a backup”POST /volumes/:name/backups/restorecurl -X POST http://localhost:8080/volumes/workspace/backups/restore \ -H "Authorization: Bearer $TOKEN" \ -d '{"backup_id": "bk_a1b2c3d4"}'409 if attached.
Delete a backup
Section titled “Delete a backup”DELETE /volumes/:name/backups/:backup_idImages
Section titled “Images”List images
Section titled “List images”GET /imagesReturns user-owned, system tier, and shared images.
Get an image
Section titled “Get an image”GET /images/:nameDelete an image
Section titled “Delete an image”DELETE /images/:nameSystem tier images can’t be deleted via this endpoint.
Pull an OCI image
Section titled “Pull an OCI image”POST /images/pullAsync — returns 202 with a task ID. Poll /tasks/<id> until it completes.
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.
Import an image
Section titled “Import an image”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.
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}Save a sandbox as an image
Section titled “Save a sandbox as an image”POST /sandboxes/:id/save-imagecurl -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.
Snapshots
Section titled “Snapshots”Named VM snapshots — memory + CPU + disk + processes.
List snapshots
Section titled “List snapshots”GET /snapshotsGet a snapshot
Section titled “Get a snapshot”GET /snapshots/:nameDelete a snapshot
Section titled “Delete a snapshot”DELETE /snapshots/:nameCreate a snapshot
Section titled “Create a snapshot”POST /sandboxes/:id/checkpointcurl -X POST http://localhost:8080/sandboxes/dev/checkpoint \ -H "Authorization: Bearer $TOKEN" \ -d '{"name": "pre-experiment"}'The source sandbox keeps running.
Resume from a snapshot
Section titled “Resume from a snapshot”POST /snapshots/:name/resumeCreates a new sandbox from the snapshot.
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 task status
Section titled “Get task status”GET /tasks/:id{ "id": "tsk_abc123", "status": "running", "progress": "downloading layers (45 MB / 142 MB)", "error": "", "result": ""}status is running, completed, or failed.
Cancel a task
Section titled “Cancel a task”DELETE /tasks/:idBest-effort cancel. Some operations are uncancellable past a certain point (e.g. a finalizing image conversion).
Image sharing
Section titled “Image sharing”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.