Sandbox API
HTTP reference for the unified VM surface and the resources behind it: files, volumes, snapshots and templates. Base URL https://api.getsolari.com. Every route takes Authorization: Bearer slr_live_… unless noted. For the TypeScript client over these routes, see the sandbox SDK reference.
This API creates, inspects and disposes of VMs. Everything a live session does rides the control WebSocket: commands, files, code.run, git, pty, computer-use input. The two exceptions are deliberate fast paths: the one-shot POST /sandboxes/:id/exec and the signed file URLs.
Contents
Sandboxes
POST/sandboxes
The unified create path for both VM kinds. Send Idempotency-Key to make it retry-safe.
Request body. All fields optional. A malformed or absent body is tolerated as {}.
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | No | "sandbox" (default, headless) or "desktop" (GUI, also returns streamUrl). Any value other than the literal "desktop" is treated as sandbox. Desktop requires a paid plan and the desktop entitlement. |
template | string | No | Built-in name or a tpl_… custom template, which must match kind and boots via restore. Defaults to base for sandbox, default for desktop. |
fromSnapshot | string | No | Boot a fork of this snapshot instead of a golden template. Must be your org's. Takes precedence over template resolution. |
cpu | integer | No | vCPUs, 1 to 16, default 2. |
memMb | integer | No | Guest RAM in MiB, 1 to 65536, default 2048. |
diskGb | integer | No | Overlay disk in GiB, 1 to 100, default 10. The load-bearing clamp; an oversized overlay pins host disk. |
envs | object | No | Per-session env injected into the guest. Non-string values are dropped. |
metadata | object | No | Opaque string labels, filterable on list via metadata.<key>=<value>. |
timeoutMs | integer | No | Rolling idle window in ms, reset by activity. Clamped to the org's max. Default 2h for sandbox, 30m for desktop. |
lifecycle | object | No | { onTimeout: "pause" | "kill", autoResume: boolean }. On expiry, pause (default) snapshots and stays resumable; kill destroys the session. |
resolution | string | No | Desktop-kind only. Default "1280x720". |
record | boolean | No | Default false. Record the session; the 201 then carries a presigned playback recordingUrl. kind: "desktop" and golden-template boots only. See the responses below. |
volumes | array | No | Volumes to mount before the session starts. Each entry is { volumeId, path }; path must be absolute and unique within the request. |
Responses
| Status | Meaning |
|---|---|
201 | Created. The VM is live and billable from this moment. Body carries sandboxId, kind, controlUrl, expiresAt, plus streamUrl for a desktop and recordingUrl when recording was presigned. |
400 | Invalid body; bad fromSnapshot type; TemplateKindMismatch; a malformed volumes entry (relative or duplicate path); RecordingRequiresDesktop for record: true on a headless sandbox; or RecordingRequiresGoldenBoot for record: true combined with fromSnapshot or a tpl_… template. |
401 | Missing, malformed, or unknown bearer key. |
402 | FeatureRequiresPlan for a desktop-kind create without entitlement; InsufficientCredit from admission. |
403 | NotEntitled. The plan includes neither desktops nor sandboxes. |
404 | Unknown snapshot, unknown tpl_… template, or an unknown/cross-org volume. |
409 | TemplateNotReady. The custom template is still building, or failed. |
429 | ConcurrencyLimitExceeded. Not retryable. The org is at its concurrent sandbox/VM cap: 1 on Free, 2 on Starter, 10 on Professional. |
500 | Unexpected failure. The gateway tears down any already-live VM, undoes partial store writes, and frees the reservation first. |
501 | volumes[] was non-empty but VOLUMES_TABLE is unconfigured on this deployment. |
502 | The host rejected the assign/restore for a non-capacity reason. |
503 | No host with free capacity, or admission was unreachable. A create fails closed rather than slip past the balance gate. Carries retryable: true. |
Recording is an ffmpeg x11grab of the guest's X display, so a headless sandbox has nothing to capture: record: true with kind: "sandbox" is rejected 400 RecordingRequiresDesktop. And the recording URL reaches the guest via the host's assign path, which the restore path has no field for, so record: true with fromSnapshot or a tpl_… template is rejected 400 RecordingRequiresGoldenBoot rather than handing back a playback URL that would 404 forever. Note POST /desktops does not yet reject the second case.
Example request
curl -s -X POST https://api.getsolari.com/sandboxes \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"template": "base",
"kind": "sandbox",
"cpu": 4,
"memMb": 8192,
"envs": { "NODE_ENV": "production" },
"metadata": { "project": "acme" },
"timeoutMs": 7200000
}'Example response
{
"sandboxId": "pool-sbx-sta:vm_3a9f:org_9f2a.cXJzdHV2d3h5ejEy",
"kind": "sandbox",
"controlUrl": "wss://api.getsolari.com/control/pool-sbx-sta%3Avm_3a9f%3Aorg_9f2a.cXJzdHV2d3h5ejEy",
"expiresAt": "2026-07-16T14:00:00.000Z"
}Forking a snapshot with a persistent volume mounted:
curl -s -X POST https://api.getsolari.com/sandboxes \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fromSnapshot": "snap_2b7d9e0a",
"volumes": [
{ "volumeId": "vol_4f3a9c1e77b24d5e8a1b0c9d2e3f4a5b", "path": "/data" }
]
}'GET/sandboxes
Lists this org's VMs of both kinds, newest first, with cursor pagination.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
state | string | No | One of starting, running, paused, archived, releasing, gone. Ignored if not a valid state. |
kind | string | No | Ignored unless exactly sandbox or desktop. |
limit | integer | No | Max rows per page. |
cursor | string | No | Opaque cursor from a previous response's nextCursor. |
metadata.<key> | string | No | Repeatable label filter. Every query param prefixed metadata. is matched against the sandbox's metadata map, e.g. ?metadata.project=acme&metadata.env=prod. Unknown non-prefixed keys are ignored. |
Responses
| Status | Meaning |
|---|---|
200 | A page of sandboxes. nextCursor is present only when another page exists. |
401 | Missing, malformed, or unknown bearer key. |
Example request
curl -s -G https://api.getsolari.com/sandboxes \
-H "Authorization: Bearer $SOLARI_API_KEY" \
--data-urlencode "state=running" \
--data-urlencode "kind=sandbox" \
--data-urlencode "limit=25" \
--data-urlencode "metadata.project=acme"Example response
{
"sandboxes": [
{
"sandboxId": "pool-sbx-sta:vm_3a9f:org_9f2a.cXJzdHV2d3h5ejEy",
"kind": "sandbox",
"state": "running",
"metadata": { "project": "acme" },
"template": "base",
"createdAt": "2026-07-16T10:00:00.000Z",
"expiresAt": "2026-07-16T12:00:00.000Z",
"cpu": 4,
"memMb": 8192
}
],
"nextCursor": "eyJvIjoyNX0"
}GET/sandboxes/:id
Returns the queryable record. This is how a client re-attaches to a session by id. An authorized call is activity and re-stamps the rolling idle deadline. On a gateway-restart cache miss it read-through-loads the durable record, so a paused VM survives a redeploy.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The signed sandbox capability. URL-encode it. Desktop session ids are signed identically and are accepted here. |
Responses
| Status | Meaning |
|---|---|
200 | The sandbox record. Internal fields (vmId, poolId, hostUrl, orgId, snapshot URIs) are never exposed. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id, all indistinguishable by design. |
Derive it from your base URL: swap http→ws / https→wss and append /control/<url-encoded id>. See Control WebSocket.
Example request
ENC=$(jq -rn --arg v "$SANDBOX_ID" '$v|@uri')
curl -s "https://api.getsolari.com/sandboxes/$ENC" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"sandboxId": "pool-sbx-sta:vm_3a9f:org_9f2a.cXJzdHV2d3h5ejEy",
"kind": "sandbox",
"state": "running",
"metadata": { "project": "acme" },
"template": "base",
"createdAt": "2026-07-16T10:00:00.000Z",
"expiresAt": "2026-07-16T12:00:00.000Z",
"cpu": 4,
"memMb": 8192
}DELETE/sandboxes/:id
Releases the host VM, frees the concurrency slot, closes the billing segment, and drops snapshot-lineage counts.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Responses
| Status | Meaning |
|---|---|
200 | Released, or already gone. Idempotent: an already-gone sandbox returns {"ok": true} and still releases any dangling reservation by session key. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
Example request
curl -s -X DELETE "https://api.getsolari.com/sandboxes/$ENC" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{ "ok": true }POST/sandboxes/:id/timeout
Sets a new rolling idle window. Subsequent activity extends by this value, not the create-time one.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Request body. Optional.
| Field | Type | Required | Description |
|---|---|---|---|
timeoutMs | integer | No | New rolling idle window in ms, clamped to the org's max. A missing or invalid value falls back to the gateway default TTL. |
Responses
| Status | Meaning |
|---|---|
200 | The new deadline, as expiresAt. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
The host's own TTL reaper is relayed best-effort as an orphan backstop. If that relay fails the call still succeeds, because the host carries a grace margin.
Example request
curl -s -X POST "https://api.getsolari.com/sandboxes/$ENC/timeout" \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "timeoutMs": 3600000 }'Example response
{ "expiresAt": "2026-07-16T13:00:00.000Z" }POST/sandboxes/:id/pause
Saves full RAM+disk state. Billing stops and it stops counting against your concurrency limit. Idempotent when already paused.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Responses
| Status | Meaning |
|---|---|
200 | Paused. Body is { "state": "paused" }. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
409 | Not pausable. The host no longer knows this VM. |
502 | The host failed the pause. Retryable. |
If the snapshot ships to S3, the gateway records its URI. That is what lets a later resume rebuild the session on any host if the original is replaced.
Example request
curl -s -X POST "https://api.getsolari.com/sandboxes/$ENC/pause" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{ "state": "paused" }POST/sandboxes/:id/resume
Restores the snapshot, re-acquires a concurrency slot, opens a fresh billing segment, and returns a fresh controlUrl. Reconnect your control channel to it.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Responses
| Status | Meaning |
|---|---|
200 | Running again. Body carries state: "running" and a new controlUrl. |
401 | Missing, malformed, or unknown bearer key. |
402 | InsufficientCredit. Admission rejected on balance. |
403 | NotEntitled. |
404 | Unknown id, bad signature, or another org's id. |
409 | Not paused, or the host cannot restore it. |
429 | ConcurrencyLimitExceeded. The org filled its cap while this session was paused. Not retryable. |
502 | The host failed the resume. |
503 | No host available to restore onto. Carries retryable: true. |
Same-host restore is preferred; the gateway falls back to any host when the snapshot is durable in S3. Admission is re-checked, but fails open on a control-plane error: a blip must not strand work you already own.
Example request
curl -s -X POST "https://api.getsolari.com/sandboxes/$ENC/resume" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"state": "running",
"controlUrl": "wss://api.getsolari.com/control/pool-sbx-sta%3Avm_3a9f%3Aorg_9f2a.cXJzdHV2d3h5ejEy"
}GET/sandboxes/:id/metrics
Proxies the host's live view of the VM's CPU, memory and disk usage.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Responses
| Status | Meaning |
|---|---|
200 | Current metrics, proxied verbatim from the host agent. |
401 | Missing, malformed, or unknown bearer key. |
404 | Either the id is unknown/cross-org, or the host no longer knows this VM. A host-unavailable error maps to 404 here, not 502. |
502 | The host failed to answer. |
Example request
curl -s "https://api.getsolari.com/sandboxes/$ENC/metrics" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"cpuPct": 12.5,
"memBytes": 734003200,
"memTotalBytes": 8589934592,
"diskBytes": 2147483648
}POST/sandboxes/:id/exec
Runs a single command to completion over the warm REST connection, so a client's first command need not pay a cold control-WS handshake. This is the dominant create→run-one-command latency win. An exec counts as activity and re-stamps the idle deadline, so a session driven purely over this path is not reaped.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
cmd | string | Yes | The binary to run. Not a shell line. |
args | string[] | No | argv tail. Ignored unless an array. |
cwd | string | No | Working directory. |
timeoutMs | integer | No | Per-command budget. |
Responses
| Status | Meaning |
|---|---|
200 | The command ran to completion. Body carries exitCode, stdout, stderr. A non-zero exit is still a 200: check exitCode, not the status. |
400 | cmd missing or not a string. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
409 | The sandbox is not running, e.g. paused, or still starting. |
502 | The host or guest could not run the command. |
cmd is the binary and args its argv tail; no shell interprets them. For shell syntax, ask for one explicitly: {"cmd":"sh","args":["-c","…"]}.
This route deliberately omits env and user: the one-shot guest path runs under the session env only. If you need those, or streaming output, or a background process, use the control channel's commands namespace.
Example request
curl -s -X POST "https://api.getsolari.com/sandboxes/$ENC/exec" \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "cmd": "sh", "args": ["-c", "ls -la /tmp | head -5"], "timeoutMs": 30000 }'Example response
{ "exitCode": 0, "stdout": "Python 3.11.2\n", "stderr": "" }GET/sandboxes/:id/ports/:port
Mints a public preview URL for a guest port: a hostname <label>-<port>.<previewDomain> plus a signed token.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
port | integer | Yes | Guest TCP port to expose, 1 to 65535. |
Responses
| Status | Meaning |
|---|---|
200 | Body carries url and token. |
400 | Port is not an integer in 1 to 65535. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
501 | Port preview is not configured on this deployment (no previewDomain). |
That is deliberate: a vmId changes on cross-host resume, which would silently break every URL you had already minted. The token binds the full sandboxId, so the preview proxy resolves the live session regardless of the current vmId.
Example request
curl -s "https://api.getsolari.com/sandboxes/$ENC/ports/3000" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"url": "https://a1b2c3d4e5f6-3000.preview.getsolari.com",
"token": "eyJzYW5kYm94SWQiOiJwb29sLXNieC1zdGE6dm1fM2E5ZiJ9.c2lnbmF0dXJl"
}Files
A signed-URL data path for moving large files in and out of a running guest over plain HTTP, bypassing the control WebSocket. Mint a URL with your API key, then hand the URL to any HTTP client.
Unlike snapshots and recordings, these bytes live inside a running microVM and are never in S3; a presigned S3 URL would resolve to NoSuchKey. The gateway signs its own HMAC token instead, pointing at a route that streams through the guest's chunked fs.download/fs.upload RPCs. Bytes are pulled from the guest only as the client reads them, so a slow reader throttles the guest rather than buffering the file in gateway memory.
GET/sandboxes/:id/files/download-url
Mints a signed, time-limited URL that streams one in-guest file out over plain HTTP.
Path and query parameters
| Name | In | Required | Description |
|---|---|---|---|
id | path | Yes | The URL-encoded signed sandbox id. |
path | query | Yes | Absolute path of the file inside the guest. Must start with / and contain no .. segment. |
Responses
| Status | Meaning |
|---|---|
200 | Body carries url and expiresAt. |
400 | path missing, relative, containing a .. segment or a NUL byte, or over 4096 chars. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
The minted URL carries no Authorization header; the token in it is the credential. It is bound to one sandbox, one org, one path and one mode (an upload token is rejected on the download route), and expires after 15 minutes by default. Minting does not touch the guest, so a path that does not exist still mints fine; the 404 surfaces when the URL is fetched.
Example request
# Mint the URL, then fetch it with no auth header at all.
URL=$(curl -s "https://api.getsolari.com/sandboxes/$ENC/files/download-url?path=%2Fhome%2Fuser%2Freport.csv" \
-H "Authorization: Bearer $SOLARI_API_KEY" | jq -r .url)
curl -s "$URL" -o report.csvExample response
{
"url": "https://api.getsolari.com/files/download?token=eyJzYW5kYm94SWQiOiJwb29sLXNieC1zdGEifQ.c2lnbmF0dXJl",
"expiresAt": "2026-07-16T12:15:00.000Z"
}GET/sandboxes/:id/files/upload-url
Mints a signed URL that accepts a PUT (or POST) whose raw body is written into the guest. Same construction, credential model and TTL as the download URL.
Path and query parameters
| Name | In | Required | Description |
|---|---|---|---|
id | path | Yes | The URL-encoded signed sandbox id. |
path | query | No | Absolute destination path inside the guest. Supplied, the token is pinned to it and the upload cannot write anywhere else. Omitted, the token is a prefix token: still scoped to this one sandbox, but the upload must name its own ?path=. |
Responses
| Status | Meaning |
|---|---|
200 | Body carries url and expiresAt. |
400 | path was supplied but is relative, contains a .. segment or a NUL byte, or is over 4096 chars. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
Example request
URL=$(curl -s "https://api.getsolari.com/sandboxes/$ENC/files/upload-url?path=%2Fhome%2Fuser%2Finput.csv" \
-H "Authorization: Bearer $SOLARI_API_KEY" | jq -r .url)
curl -s -X PUT "$URL" --data-binary @input.csvExample response
{
"url": "https://api.getsolari.com/files/upload?token=eyJtb2RlIjoidXBsb2FkIn0.c2lnbmF0dXJl",
"expiresAt": "2026-07-16T12:15:00.000Z"
}GET/files/download
The data path a download URL points at. Deliberately not under /sandboxes/:id, and deliberately unauthenticated in the bearer sense: the URL is handed to arbitrary HTTP clients (a browser, curl -O) that send no Authorization header, so the signed token is the sole credential. Fetching is session activity and re-stamps the idle deadline.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The signed token from GET /sandboxes/:id/files/download-url. |
Responses
| Status | Meaning |
|---|---|
200 | The file's bytes, as application/octet-stream with a Content-Disposition filename taken from the guest path. The first chunk is fetched before the 200 is committed, so a missing file is a clean 404 rather than a truncated body. |
403 | Token missing, malformed, tampered with, expired, or minted for upload rather than download. |
404 | The sandbox is gone, the token's org does not own it, or the file does not exist in the guest. |
409 | The sandbox is not running (paused, releasing, gone), so there is no live guest to read from. |
502 | The guest could not be reached, or the transfer failed mid-stream. |
Example request
# No Authorization header; the token is the credential.
curl -s "https://api.getsolari.com/files/download?token=$FILE_TOKEN" -o report.csvExample response
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="report.csv"
<raw bytes>PUT/files/upload
The data path an upload URL points at. The raw request body is written to the guest path in 1 MiB chunks. Not multipart, not base64: the body IS the file. The write truncates, so uploading to an existing path replaces it and an empty body clears the file. Parent directories are created as needed. Uploading is session activity and re-stamps the idle deadline.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | The signed token from GET /sandboxes/:id/files/upload-url. |
path | string | No | Destination path. Required only for a prefix token, one minted with no path. If the token was minted with a path, that path wins and any ?path= here is ignored. |
Request body. Required. The raw file bytes, as application/octet-stream.
Responses
| Status | Meaning |
|---|---|
200 | Written. Body carries ok, the guest path written, and bytes. |
400 | A prefix token with no ?path=, an invalid path, or the guest refused the write (permission denied, path is a directory). |
403 | Token missing, malformed, tampered with, expired, or minted for download rather than upload. |
404 | Unknown sandbox, or the token's org does not own it. |
409 | The sandbox is not running, so there is no live guest to write to. |
502 | The guest could not be reached, or the transfer failed mid-write. |
Example request
curl -s -X PUT "https://api.getsolari.com/files/upload?token=$FILE_TOKEN" \
--data-binary @input.csvExample response
{ "ok": true, "path": "/home/user/input.csv", "bytes": 20480 }POST/files/upload
Identical to PUT /files/upload; both verbs share one handler. Same query parameters, same raw octet-stream body, same responses. It exists because some clients cannot issue a PUT.
Example request
curl -s -X POST "https://api.getsolari.com/files/upload?token=$FILE_TOKEN" \
--data-binary @input.csvExample response
{ "ok": true, "path": "/home/user/input.csv", "bytes": 20480 }Volumes
Durable, session-independent storage. These routes are metadata CRUD only: mounting happens at session create via the volumes[] field on POST /sandboxes / POST /desktops.
If VOLUMES_TABLE is unset on the deployment (local, dev), the entire volumes surface is disabled and every route here returns 501. Create also returns 501 when a request carries a non-empty volumes[], rather than silently handing back a session with nothing mounted.
POST/volumes
Creates volume metadata.
Request body. All fields optional.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Trimmed and truncated to 200 chars. Blank or absent → "volume". |
sizeMb | integer | No | Advisory hint only. s3fs is elastic and this is not enforced as a quota. Stored only when a sane positive number; capped at 1 TiB (1048576 MiB). |
metadata | object | No | Opaque string labels. Non-string values are dropped. |
Responses
| Status | Meaning |
|---|---|
201 | Created. Body carries volumeId (vol_ + 32 hex, which doubles as the S3 prefix), name, createdAt, and sizeMb/metadata when set. orgId is never exposed. |
400 | Malformed request body. |
401 | Missing, malformed, or unknown bearer key. |
501 | Volumes are not configured on this deployment. |
Example request
curl -s -X POST https://api.getsolari.com/volumes \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "project-data", "sizeMb": 10240, "metadata": { "project": "acme" } }'Example response
{
"volumeId": "vol_4f3a9c1e77b24d5e8a1b0c9d2e3f4a5b",
"name": "project-data",
"sizeMb": 10240,
"createdAt": "2026-07-16T10:00:00.000Z",
"metadata": { "project": "acme" }
}GET/volumes
Lists this org's volumes, newest first. No pagination or filtering.
Responses
| Status | Meaning |
|---|---|
200 | All of this org's volumes, as { "volumes": [...] }. sizeMb and metadata are omitted entirely when unset, not sent as null. |
401 | Missing, malformed, or unknown bearer key. |
501 | Volumes are not configured on this deployment. |
Example request
curl -s https://api.getsolari.com/volumes \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"volumes": [
{
"volumeId": "vol_4f3a9c1e77b24d5e8a1b0c9d2e3f4a5b",
"name": "project-data",
"sizeMb": 10240,
"createdAt": "2026-07-16T10:00:00.000Z",
"metadata": { "project": "acme" }
},
{
"volumeId": "vol_8e1d2c3b44a55f6e7d8c9b0a1f2e3d4c",
"name": "volume",
"createdAt": "2026-07-15T09:00:00.000Z"
}
]
}GET/volumes/:id
Returns one volume.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Volume id: vol_ + 32 hex chars. |
Responses
| Status | Meaning |
|---|---|
200 | The volume. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown volume, or one owned by another org. The same response by design, so the gateway never confirms another org's ids. |
501 | Volumes are not configured on this deployment. |
Example request
curl -s https://api.getsolari.com/volumes/vol_4f3a9c1e77b24d5e8a1b0c9d2e3f4a5b \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"volumeId": "vol_4f3a9c1e77b24d5e8a1b0c9d2e3f4a5b",
"name": "project-data",
"sizeMb": 10240,
"createdAt": "2026-07-16T10:00:00.000Z",
"metadata": { "project": "acme" }
}DELETE/volumes/:id
Deletes the volume's metadata row only. Idempotent.
This route performs no attachment check. If a running VM currently has this volume mounted via volumes[], the delete succeeds anyway: the metadata row disappears while the guest's s3fs mount stays live and writable. Nothing warns you, and there is no 409 for this case.
Practical consequences:
- Writes from the still-running guest keep landing in S3 under the deleted volume's prefix, where nothing will ever list or reclaim them via the API.
- The volume cannot be re-attached to a new session (
404on create), so the data is reachable only by the VMs that already had it mounted, until they die. - You must check for attachments yourself before deleting.
Only the metadata row is removed. The S3 prefix's bytes are left for the volumes-bucket lifecycle/GC to reap; the gateway holds no S3 client for that bucket. Deleting a volume therefore does not immediately stop storage costs.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Volume id: vol_ + 32 hex chars. |
Responses
| Status | Meaning |
|---|---|
200 | Metadata deleted, or already gone. Says nothing about attachments or bytes. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown volume, or one owned by another org. |
501 | Volumes are not configured on this deployment. |
Example request
curl -s -X DELETE https://api.getsolari.com/volumes/vol_4f3a9c1e77b24d5e8a1b0c9d2e3f4a5b \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{ "ok": true }Snapshots
Point-in-time RAM+disk captures of a VM, forkable via fromSnapshot and promotable to templates.
POST/sandboxes/:id/snapshots
Captures RAM+disk to S3 and registers it. Boot a fork later with POST /sandboxes {"fromSnapshot": "<snapshotId>"}.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Request body. Optional.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Human-facing label. |
Responses
| Status | Meaning |
|---|---|
201 | Created. Body carries snapshotId, sizeBytes and createdAt, and notably no parent, despite the SDK type declaring one. Read lineage from GET /snapshots/:id. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, bad signature, or another org's id. |
409 | Not snapshottable. The host no longer knows this VM. |
502 | The host failed the snapshot. |
A snapshot taken from a snapshot-forked sandbox becomes a child of the snapshot that sandbox booted from. That DAG is what powers descendant delete-protection (409 SnapshotHasChildren).
Example request
curl -s -X POST "https://api.getsolari.com/sandboxes/$ENC/snapshots" \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "after-deps-installed" }'Example response
{
"snapshotId": "snap_2b7d9e0a",
"sizeBytes": 2147483648,
"createdAt": "2026-07-16T11:00:00.000Z"
}POST/sandboxes/:id/revert
Restores the running VM in place from one of your org's snapshots. The snapshot must belong to the same org as the sandbox.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The URL-encoded signed sandbox id. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
snapshotId | string | Yes | The snapshot to restore from. |
Responses
| Status | Meaning |
|---|---|
200 | Reverted. |
400 | snapshotId missing or not a string. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown sandbox id, or unknown/cross-org snapshot. |
409 | Not revertable. The host no longer knows this VM. |
502 | The host failed the revert. |
Example request
curl -s -X POST "https://api.getsolari.com/sandboxes/$ENC/revert" \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "snapshotId": "snap_2b7d9e0a" }'Example response
{ "ok": true }GET/snapshots
Lists this org's snapshots.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
template | string | No | Filter by originating template. |
kind | string | No | Ignored unless exactly sandbox or desktop. |
sandboxId | string | No | Filter to snapshots taken from one sandbox. |
limit | integer | No | Max rows. |
Responses
| Status | Meaning |
|---|---|
200 | Matching snapshots. |
401 | Missing, malformed, or unknown bearer key. |
Example request
curl -s -G https://api.getsolari.com/snapshots \
-H "Authorization: Bearer $SOLARI_API_KEY" \
--data-urlencode "kind=sandbox" \
--data-urlencode "template=base" \
--data-urlencode "limit=25"Example response
{
"snapshots": [
{
"id": "snap_2b7d9e0a",
"parent": null,
"name": "after-deps-installed",
"sizeBytes": 2147483648,
"createdAt": "2026-07-16T11:00:00.000Z",
"kind": "sandbox",
"template": "base"
}
]
}GET/snapshots/:id
Returns one snapshot, including its lineage.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Snapshot id. |
Responses
| Status | Meaning |
|---|---|
200 | The snapshot. The id field is id, not snapshotId, unlike the create response. parent and name are explicitly null when unset, not omitted. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, or another org's snapshot. |
Example request
curl -s https://api.getsolari.com/snapshots/snap_2b7d9e0a \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"id": "snap_2b7d9e0a",
"parent": "snap_1a6c8d9f",
"name": "after-deps-installed",
"sizeBytes": 2147483648,
"createdAt": "2026-07-16T11:00:00.000Z",
"kind": "sandbox",
"template": "base"
}DELETE/snapshots/:id
Deletes a snapshot.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Snapshot id. |
Responses
| Status | Meaning |
|---|---|
200 | Deleted. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, or another org's snapshot. |
409 | SnapshotHasChildren. A live VM still descends from this snapshot. Unlike volumes, snapshots do enforce dependency protection. |
Example request
curl -s -X DELETE https://api.getsolari.com/snapshots/snap_2b7d9e0a \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{ "ok": true }POST/snapshots/:id/promote
Registers the snapshot in the durable template registry, minting a tpl_… id you can pass as template on create. The template inherits the snapshot's kind and lands status: "ready" immediately. There is no build.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Snapshot id to promote. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Template name. Must be non-empty. |
Responses
| Status | Meaning |
|---|---|
200 | Promoted. Body carries templateId and name. Note this is 200, not 201. |
400 | name missing or empty. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, or another org's snapshot. |
The gateway's snapshot store is otherwise in-memory. A promoted template is durable: it survives a gateway restart and shows up in GET /templates.
Example request
curl -s -X POST https://api.getsolari.com/snapshots/snap_2b7d9e0a/promote \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "acme-preinstalled" }'Example response
{ "templateId": "tpl_9a8b7c6d", "name": "acme-preinstalled" }Templates
Built-in golden images plus custom bring-your-own-image builds.
POST/templates
Starts an asynchronous custom image build and returns 202 immediately with the row at status: "building". Poll GET /templates/:id until ready or failed; tail progress with GET /templates/:id/logs. A build consumes a real microVM, so it is gated to paid plans.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Non-empty template name. |
kind | string | Yes | sandbox or desktop. |
compiled | object | Yes | The compiled build recipe. See the fields below. |
cpu, memMb | integer | No | Recorded on the row; ignored unless a number. |
compiled fields
| Field | Type | Required | Description |
|---|---|---|---|
kind | string | No | Any value other than "desktop" is normalised to sandbox. |
base | string | No | Golden base to build on. Defaults by kind: base for sandbox, workstation for desktop. |
fromTemplate | string | No | Build on top of an existing custom template instead of a golden base. |
packages | string[] | No | System packages. Non-string entries are filtered out. |
steps | object[] | No | Build steps. Each needs an op from pip, run, env, workdir, entrypoint, cmd; any other value rejects the whole recipe with a 400. Remaining fields are op-specific. |
localFiles | array | No | Not supported. A non-empty list is rejected 400 LocalFilesUnsupported. Fetch files inside the build with a run step instead. |
Responses
| Status | Meaning |
|---|---|
202 | Build accepted and running asynchronously. The durable row is written before the build starts, so the template survives a gateway restart and appears in GET /templates while still building. |
400 | Invalid body, missing/invalid name or kind, unparseable recipe, or LocalFilesUnsupported. |
401 | Missing, malformed, or unknown bearer key. |
402 | FeatureRequiresPlan. Custom template builds require a paid plan. |
There is no build-id concept in this API. The response is the template row; poll and tail logs by templateId. (The TypeScript SDK's BuildTemplateResponse type declares a buildId, which no route returns and which the client itself discards.)
Example request
curl -s -X POST https://api.getsolari.com/templates \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "acme-python",
"kind": "sandbox",
"cpu": 2,
"memMb": 4096,
"compiled": {
"kind": "sandbox",
"base": "base",
"packages": ["ffmpeg"],
"steps": [ { "op": "pip", "packages": ["pandas"] } ],
"localFiles": []
}
}'Example response
{
"templateId": "tpl_9a8b7c6d",
"name": "acme-python",
"kind": "sandbox",
"status": "building",
"builtin": false,
"cpu": 2,
"memMb": 4096,
"createdAt": "2026-07-16T10:00:00.000Z"
}GET/templates
Returns the platform's built-in golden templates plus this org's custom templates.
Responses
| Status | Meaning |
|---|---|
200 | Built-ins followed by this org's customs. Built-ins carry builtin: true, status: "ready" and a description; customs carry builtin: false plus cpu/memMb/error/createdAt. The internal snapshotUri is never exposed. |
401 | Missing, malformed, or unknown bearer key. |
Example request
curl -s https://api.getsolari.com/templates \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"templates": [
{
"templateId": "base",
"name": "base",
"kind": "sandbox",
"status": "ready",
"builtin": true,
"description": "Ubuntu base with Python 3, Node, and git"
},
{
"templateId": "tpl_9a8b7c6d",
"name": "acme-python",
"kind": "sandbox",
"status": "ready",
"builtin": false,
"cpu": 2,
"memMb": 4096,
"createdAt": "2026-07-16T10:00:00.000Z"
}
]
}GET/templates/:id
Returns one template. Poll this route's status to await a build.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | A built-in name (e.g. base, workstation) or a minted tpl_… id. Built-ins are checked first and are visible to every org; custom templates resolve only within your own org. |
Responses
| Status | Meaning |
|---|---|
200 | The template. status is building, ready or failed; error is populated only when failed. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, or another org's custom template. |
Example request
curl -s https://api.getsolari.com/templates/tpl_9a8b7c6d \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"templateId": "tpl_9a8b7c6d",
"name": "acme-python",
"kind": "sandbox",
"status": "failed",
"builtin": false,
"cpu": 2,
"memMb": 4096,
"error": "pip install pandas failed (exit 1)",
"createdAt": "2026-07-16T10:00:00.000Z"
}DELETE/templates/:id
Deletes a custom template.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | A tpl_… id. |
Responses
| Status | Meaning |
|---|---|
200 | Deleted. |
400 | Built-in templates are immutable and cannot be deleted. |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, or another org's template. |
409 | TemplateBuilding. The row is being mutated by the build orchestrator and its snapshot does not exist yet. |
Sandboxes already created from a ready template hold no ongoing dependency on it; restore is a one-time S3 copy. This is the opposite of snapshots, which do enforce descendant protection.
Example request
curl -s -X DELETE https://api.getsolari.com/templates/tpl_9a8b7c6d \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{ "ok": true }GET/templates/:id/logs
Tails a template build's logs. Poll-based: it returns the whole accumulated log buffer each call, not a delta and not a stream. Poll while status is building.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Custom template id (tpl_…). Built-ins have no build and no logs. |
Responses
| Status | Meaning |
|---|---|
200 | Build status plus the log lines so far. error is present only when status: "failed". |
401 | Missing, malformed, or unknown bearer key. |
404 | Unknown id, or another org's template. |
logs comes from an in-memory build job. It is empty once that job is evicted, or after a gateway restart, even for a template that really did build. No SDK binding calls this route today; it is an HTTP-only surface.
Example request
curl -s https://api.getsolari.com/templates/tpl_9a8b7c6d/logs \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
{
"templateId": "tpl_9a8b7c6d",
"status": "failed",
"error": "pip install pandas failed (exit 1)",
"logs": [
"[build] pip install pandas",
"ERROR: Could not find a version that satisfies the requirement pandas"
]
}Control WebSocket
WSwss://api.getsolari.com/control/:id
The RPC channel that carries the bulk of what a live session does. REST does not cover commands, files, code.run, git, pty, or computer-use input. They all ride this channel. It cannot be expressed as REST, so it is documented here rather than as endpoints.
| Aspect | Contract |
|---|---|
| URL | The controlUrl from POST /sandboxes, POST /desktops or POST /sandboxes/:id/resume. Re-attaching by id? GET returns no controlUrl, so derive it: take the REST base URL, swap http→ws / https→wss, and append /control/<url-encoded id>. |
| Auth | Send Authorization: Bearer <apiKey> as a WebSocket upgrade header, the same key used for REST. The URL path segment is the signed session id and is itself a capability. |
| Framing | Newline-delimited JSON: one JSON object per frame, followed by \n. Readers accumulate bytes, split on \n, trim, skip empty lines, and must not crash on a non-JSON line. |
| Timeouts | Connect 15s; default call 300s. Up to 3 connect attempts on a fast failure, backoff 150 × attempt ms. A full connect timeout is not retried. |
Right after a snapshot restore the guest briefly accepts only one vsock control connection, so a concurrent upgrade can get a transient 502 guest_unreachable while the VM is in fact live.
Frame kinds
| Kind | Shape | Notes |
|---|---|---|
| RPC request | {"id":"1","method":"cmd.start","params":{}} | id is an opaque string from a per-channel counter starting at "1". |
| RPC reply | {"id":"1","ok":true,"result":{}} / {"id":"1","ok":false,"error":{"code":"…","message":"…"}} | error may also be a plain string. |
| v1 streamed exec | {"id":"1","stream":"stdout","data":"…"} | Legacy. Route to the per-id stream handler; the call stays pending until its terminal {id, ok} reply. Do not crash on these. |
| v2 async stream | {"type":"cmd.data","cmdId":"c1","stream":"stdout","base64":"…"} then {"type":"cmd.exit","cmdId":"c1","exitCode":0} | Not correlated to a request id. Dispatch by (type, streamId) where streamId = cmdId ?? ptyId ?? watchId. |
The guest can emit cmd.data/cmd.exit before the cmd.start reply arrives, because output pumps start before the reply is sent, so a frame can beat the caller's handler registration. A correct client must buffer async frames whose (type, streamId) has no handler yet (“orphan frames”, bounded around 1024) and flush them the instant one registers.
Likewise on close: reject every in-flight RPC call and every in-flight stream wait. A commands.run awaiting cmd.exit is frame-based, not a pending RPC, so it needs a separate stream-closer registry or it hangs forever.
Namespaces
| Namespace | Methods | Notes |
|---|---|---|
commands | cmd.start, cmd.stdin, cmd.kill | cmd.start {cmd,args?,cwd?,env?,user?} → {cmdId}. No shell: cmd is the binary and args its argv tail, which is what makes it injection-safe. Output arrives as async cmd.data frames and terminates with cmd.exit. |
files | fs.read, fs.write, fs.list, fs.stat, fs.mkdir, fs.remove, fs.rename | Wire-shape gotcha: an entry is {name, dir, size} and a stat is {name, dir, size, mode, modTimeMs}. The field is dir, not isDir; the time field is modTimeMs (unix millis), not modTime. |
code.run | code.run | {code, language?, contextId?} → {results, error?, charts}. language defaults server-side to python. charts is a client-side convenience: flatten every results[i].chart into a top-level array. |
git | clone, status, add, commit, push, pull, checkout, branches, log | Not a server RPC and not a REST route. Git is client-side composition: each method shells out via commands.run("git", {args, cwd}) and parses stdout locally. Over raw HTTP you would compose the same calls yourself. |
pty | pty.create, pty.write, pty.resize, pty.kill | Interactive terminal. |
| computer-use | input.mouse, input.key, screenshot, display.set, clipboard.get, clipboard.set, process.list, process.kill, ports.list, health | Desktop-kind sessions only. The live pixels come over the separate RFB stream. |
A client's commands.run uses the one-shot POST /sandboxes/:id/exec when the channel is not already connected and there are no streaming callbacks, env, user, or background, skipping the cold WS handshake entirely. Over raw HTTP, reach for exec first for the same reason.
Example exchange
→ {"id":"1","method":"cmd.start","params":{"cmd":"python3","args":["-u","train.py"],"cwd":"/home/user"}}
← {"id":"1","ok":true,"result":{"cmdId":"c1"}}
← {"type":"cmd.data","cmdId":"c1","stream":"stdout","base64":"ZXBvY2ggMS8xMAo="}
← {"type":"cmd.data","cmdId":"c1","stream":"stdout","base64":"ZXBvY2ggMi8xMAo="}
← {"type":"cmd.exit","cmdId":"c1","exitCode":0}