The API
The isolance API lets you automate sandboxes, MCP servers, connectors, your users' logins and billing with an API key. It's a JSON API over HTTPS at https://api.caas.sh/v1, the caas CLI (The CLI) maps its commands onto it one to one, and the TypeScript SDK (The TypeScript SDK) offers it as typed methods.
This guide covers authentication, the conventions every route follows, and the routes for each resource. The other guides explain what each resource does.
Authenticate
Create an API key
In the console, open Settings.
In the API keys card, enter a Key name, such as
ci, and choose Create key. If you're an owner, you can also check Billing access, which lets the key read the balance and add funds.Copy the key. It starts with
ck_, and it isn't shown again.
Owners and operators can create keys; viewers can't. A key from the console doesn't expire: it works until you choose Revoke beside it. Keys, members and invitations are managed only in the console, never with an API key.
Send the key
Send the key as a bearer token:
export CAAS_API_KEY=<api-key>
curl https://api.caas.sh/v1/sandboxes \
-H "Authorization: Bearer $CAAS_API_KEY"A key belongs to one workspace, and every request it sends acts in that workspace: no path names a workspace. A sandbox, server or connector of another workspace, by ID or name, answers 404, as one that doesn't exist does. To work in another workspace, use a key created there.
Scopes
A key's scopes say which routes it may call. A key from the console has every scope but the two billing ones, and Billing access adds those.
| Scope | What it allows |
|---|---|
sandbox:read | Read sandboxes, their execs, logs and snapshots; connectors, your users and their usage; the workspace's settings and audit log |
sandbox:write | Create, change, start, stop and delete sandboxes; take, restore and delete snapshots; change the workspace's settings; make connect links, get and see your users' sign-in codes, disconnect and remove your users; change connectors and their workspace accounts (owners only); add, change and delete schedules, and run them now (with sandbox:execute) |
sandbox:execute | Run and cancel execs; read, write and delete files |
mcp:read | Read MCP servers, their client tokens, traces and stats |
mcp:write | Create, change and delete MCP servers (with sandbox:write, which a delete that leaves the server's sandbox doesn't need), and mint and revoke client tokens |
registry:read | Pull images and list tags at registry.caas.sh |
registry:write | Push and delete images at registry.caas.sh, and pull them |
billing:read | Read the balance, usage and auto-recharge, and see billing records in the audit log |
billing:write | Add funds and change auto-recharge |
Roles
A key can do what its scopes allow, within the role its creator has in the workspace now:
| Role | Scopes the role allows |
|---|---|
| Owner | All of them |
| Operator | All but billing:read and billing:write |
| Viewer | sandbox:read, mcp:read, registry:read and billing:read |
So if an operator who created a key becomes a viewer, the key can only read, and if they leave the workspace, the key gets 403. Adding, changing and removing connectors, and connecting or disconnecting their workspace accounts, needs an owner's key.
A revoked key, or a changed role, takes effect within a minute. Requests that grant access check the key at once instead: creating a sandbox without a user, changing a sandbox's labels, minting MCP client tokens, creating an MCP server, every change of a connector or its workspace account, and adding funds or changing auto-recharge.
Requests and responses
Send request bodies as JSON, with
Content-Type: application/json, except for files. A body larger than its route takes gets413.Objects are flat: what you set and what isolance reports sit side by side. An unknown field is refused, naming it.
A path's
<sandbox>is a sandbox's ID or its name, and<server>an MCP server's ID or name.Times are milliseconds since the Unix epoch. Money is in micro-USD: 1,000,000 is $1.
A list answers
{ "<resource>": [...] }, and a paged one itsnextCursorbeside it: pass it back as?cursor=for the next page. It'snullon the last page.Deleting a sandbox or a snapshot answers
{ "id": "…", "deleted": true }. An MCP server's answer adds"sandboxDeleted", whether its sandbox went with it; a connector's adds how many users'connectionsand whether anaccountwent with it; a file answers{ "path": "…", "deleted": true }. Revoking a client token answers the token.
Errors
Every error has the same JSON body:
{ "error": { "code": "invalid_request", "message": "ports[0]: port must be from 1024 to 65535", "field": "ports" } }field names the request field a validation error is about; a nested one is dotted, as in sandbox.size. Other fields some errors carry sit inside error: a billing refusal's billingUrl, a 5xx's requestId.
code | Status | Meaning |
|---|---|---|
invalid_request | 400 | A field, query or header the API refuses, or a body that isn't JSON |
unauthorized | 401 | No API key, or one that's unknown or revoked |
payment_method_required, out_of_funds, spend_cap_reached | 402 | The workspace can't run sandboxes: no card on file, a balance of $0 or below, or this month's spend cap reached |
forbidden | 403 | The key's scopes or its creator's role don't allow the request |
tenant_suspended | 403 | The workspace is suspended. Billing still works, so an owner can pay. |
not_found | 404 | No such route, sandbox, server, exec, file, snapshot or connector |
method_not_allowed | 405 | The route doesn't take this method; the Allow header lists the ones it does |
conflict | 409 | The request conflicts with the resource's state, such as restoring a snapshot into a running sandbox, or an idempotency key reused with another body |
name_taken | 409 | The name belongs to another sandbox or server |
not_running | 409 | An exec or a file transfer for a sandbox you stopped yourself |
too_large | 413 | The body is too large |
unsupported_media_type | 415 | A body whose Content-Type the route doesn't take |
rate_limited | 429 | A rate limit was reached; see Rate limits |
unavailable | 502, 503 | A temporary failure. Try again shortly. |
timeout | 504 | A file transfer or snapshot is still under way. Check again shortly. |
Every 402 has a billingUrl: the console page where an owner adds funds or a card. See Billing and usage.
Request IDs
Every response has an x-caas-request-id header that names the request. isolance logs everything the request did under that ID, and the audit log records it with each change. Quote it when you report a problem.
Rate limits
| Requests | Limit |
|---|---|
| To the API, from one client address | 600 a minute |
| To the API, with one API key | 600 a minute |
| To one MCP endpoint, with a valid client token | 1,200 a minute |
| To the MCP gateway without a valid client token, from one client address | 600 a minute |
A request over a limit gets 429 with "code": "rate_limited" and a Retry-After header. Limits are counted at each edge location separately.
Idempotency keys
Requests that create something you mustn't create twice take an Idempotency-Key header of 8 to 128 letters, digits, _ and -. If you get no answer, or a 5xx, send the request again with the same key and body. If the first one went through, you get its result, and nothing is created twice.
| Request | A repeat with the same key | With another body |
|---|---|---|
POST /v1/sandboxes | 200 with the same sandbox | 409 |
POST /v1/sandboxes/<sandbox>/exec | That exec; the key is its ID | 409 |
POST /v1/mcp-servers | 200 with the server, without its tokens' secrets | 409 |
POST /v1/billing/top-ups (required) | The same top-up | 409 |
Without the header, each request creates something new.
Sandboxes
See Sandboxes for every field and what it does.
Create a sandbox
curl https://api.caas.sh/v1/sandboxes \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: build-agent-0001" \
-d '{
"name": "build-agent",
"image": "default",
"size": "l",
"env": { "MODE": "ci" },
"ports": [{ "port": 3000, "public": true }],
"egress": ["api.github.com", "*.npmjs.org"],
"labels": { "team": "ci" }
}'{ "image": "default" } alone is a whole request. The answer is 201 Created with the sandbox, which starts on its own:
{
"id": "7c9e4a2b-1f3d-4b6e-8a5c-3d2f1e0b9a87",
"name": "build-agent",
"image": "default",
"command": null,
"env": { "MODE": "ci" },
"workdir": "/workspace",
"size": "l",
"volumes": [{ "path": "/workspace" }],
"ports": [{ "port": 3000, "public": true }],
"egress": ["api.github.com", "*.npmjs.org"],
"user": null,
"idleStopMinutes": 15,
"labels": { "team": "ci" },
"connectors": [{ "id": "github", "name": "GitHub", "hosts": ["api.githubcopilot.com", "api.github.com", "github.com"], "account": "workspace" }],
"provider": "cloudflare",
"providerId": null,
"state": "creating",
"stateMessage": null,
"urls": [],
"pendingRestart": [],
"createdAt": 1791797400000,
"startedAt": null,
"stoppedAt": null,
"usage": { "startedAt": null, "costPerHourMicroUsd": 0, "today": { "microUsd": 0, "egressBytes": 0 }, "lifetimeMicroUsd": 0 },
"signIn": null
}state moves through creating, starting and running; read the sandbox until it runs. urls list the provider's own URL of each port it gives one for, while the sandbox runs (see URLs). A failed start leaves it failed, with the reason in stateMessage. signIn is null unless the sandbox is one of your users' and needs a sign-in they make with a code, such as Codex (ChatGPT)'s, which they haven't made: then it's the code they enter, which …/start and …/restart answer too (see Sign users in with a code).
Sandbox routes
| Route | Scope | What it does |
|---|---|---|
POST /v1/sandboxes | sandbox:write | Creates a sandbox |
GET /v1/sandboxes | sandbox:read | Lists sandboxes, newest first: { sandboxes, nextCursor }. Filter with ?label=key=value (repeat it) and ?provider=; ?limit= 1 to 100, 50 by default. |
GET /v1/sandboxes/<sandbox> | sandbox:read | Reads one |
PATCH /v1/sandboxes/<sandbox> | sandbox:write | Replaces the fields it names; pendingRestart lists what waits for the next start |
DELETE /v1/sandboxes/<sandbox> | sandbox:write | Deletes it, with its volumes and snapshots |
POST …/start, …/stop, …/restart | sandbox:write | Starts, stops, or stops and starts it: 200 once done within about 25 seconds, else 202 while it changes |
POST …/exec | sandbox:execute | Runs a command; see Execs |
GET …/exec/<exec-id> | sandbox:read | Reads an exec |
DELETE …/exec/<exec-id> | sandbox:execute | Cancels a running exec |
GET …/logs | sandbox:read | Its log; see Logs |
GET, PUT, DELETE …/files/<path> | sandbox:execute | Its files; see Files |
GET …/snapshots, POST …/snapshots | sandbox:read, sandbox:write | Lists snapshots, or takes one; see Snapshots |
DELETE …/snapshots/<snapshot-id> | sandbox:write | Deletes a snapshot |
POST …/restore | sandbox:write | Restores a snapshot into a stopped sandbox |
… is /v1/sandboxes/<sandbox>.
Execs
curl https://api.caas.sh/v1/sandboxes/build-agent/exec \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: run-tests-0001" \
-d '{ "command": ["npm", "test"], "env": { "CI": "1" }, "workdir": "/workspace/app", "timeoutSeconds": 1800 }'| Field | Value |
|---|---|
command | Required. 1 to 32 arguments of up to 512 characters; no shell is added |
stdin | Text sent to the command, up to 64 KiB |
env | Variables over the sandbox's |
workdir | Its directory; the sandbox's by default |
timeoutSeconds | 1 to 7,200; 600 by default. Up to 86,400 when detached |
detach | true answers at once while it runs |
The answer is 200 once the command ended (within about five minutes), or 202 while it runs:
{ "execId": "run-tests-0001", "state": "succeeded", "detach": false, "createdAt": 1791797400000, "endedAt": 1791797412000,
"exitCode": 0, "signal": null, "stdout": "…", "stderr": "", "stdoutTruncated": false }state is submitting, running, succeeded, failed, timed_out, canceled or lost; stdout and stderr are null until it ends, then up to 1 MiB each. A command sent to a sandbox that stopped while idle starts it first.
Logs
GET /v1/sandboxes/<sandbox>/logs answers { "entries": [{ "at", "stream", "message", "kind" }] }: the newest 1,000 entries at or after ?since= (milliseconds or an ISO 8601 time), oldest first. stream is system for what isolance recorded, with its kind, or stdout and stderr for what the sandbox's command wrote. With ?follow=true, the answer is newline-delimited JSON (application/x-ndjson): those entries, then each new one as it comes, for up to 10 minutes; follow on with since.
Files
| Route | What it does |
|---|---|
GET …/files/<path> | A file's bytes as application/octet-stream (?tail=N for its last N bytes), or a directory's listing as JSON: { "path", "entries": [{ "name", "type", "size" }] }, up to 1,000 entries |
PUT …/files/<path> | Writes the body as the file, up to 100 MiB, creating its directories: { "path", "bytes" }. With ?extract=true, the body is a gzipped tar unpacked into the directory <path>. |
DELETE …/files/<path> | Deletes the file; a directory needs ?recursive=true |
<path> is the absolute path with each segment percent-encoded, as in /v1/sandboxes/build-agent/files/workspace/my%20file.txt; …/files/ is /, which can only be read. Paths under /proc, /sys, /dev and /workspace/.caas are refused.
curl -X PUT "https://api.caas.sh/v1/sandboxes/build-agent/files/workspace?extract=true" \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/gzip" \
--data-binary @project.tar.gzSnapshots
GET …/snapshots answers { "snapshots": [{ "id", "name", "kind", "createdAt", "size", "current" }], "storedBytes" }, newest first. POST …/snapshots with an optional { "name": "before-upgrade" } takes one of a running sandbox: { "snapshot" }. POST …/restore with { "snapshot": "<id or manual snapshot name>" } restores one into a stopped or failed sandbox and starts it, answering the sandbox and restoredSnapshotId.
MCP servers
| Route | Scope | What it does |
|---|---|---|
POST /v1/mcp-servers | mcp:write, sandbox:write | Creates a server, in a new sandbox or one you have |
GET /v1/mcp-servers | mcp:read | Lists servers: { mcpServers, nextCursor } |
GET /v1/mcp-servers/<server> | mcp:read | Reads one |
PATCH /v1/mcp-servers/<server> | mcp:write, sandbox:write | Renames it, or changes its port and path |
DELETE /v1/mcp-servers/<server> | mcp:write; sandbox:write too when it deletes the server's sandbox | Deletes it, and the sandbox its create made unless ?keepSandbox=true |
GET /v1/mcp-servers/<server>/tokens | mcp:read | Lists its client tokens, never a secret |
POST /v1/mcp-servers/<server>/tokens | mcp:write | Mints a token: { "label", "expiresAt", "allowedTools" }; the answer has its secret, once |
DELETE /v1/mcp-servers/<server>/tokens/<token-id> | mcp:write | Revokes a token |
GET /v1/mcp-servers/<server>/traces | mcp:read | Its newest 100 requests |
GET /v1/mcp-servers/<server>/stats | mcp:read | Its gateway's last 24 hours |
curl https://api.caas.sh/v1/mcp-servers \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "search-mcp",
"sandbox": { "image": "registry.caas.sh/<tenant-id>/search-mcp@sha256:…", "egress": ["api.search.example.com"] },
"transport": { "http": { "port": 8080 } },
"tokens": [{ "label": "laptop-claude", "allowedTools": ["search.query"] }]
}'| Field | Value |
|---|---|
name | Required. 1 to 63 lowercase letters, digits and hyphens, unique among the workspace's servers |
sandbox | A new sandbox, exactly as POST /v1/sandboxes takes it |
sandboxId | Instead of sandbox: an existing sandbox, by ID or name. It hosts at most one server. |
transport | { "http": { "port": 3000, "path": "/mcp" } }: the port the sandbox's server serves MCP over HTTP on, and its path (/mcp by default) |
tokens | The client tokens to mint, up to 20; one Initial client token without it |
The answer, 201, is the server: its id, its endpoint url (https://mcp.caa.sh/<id>), the sandboxId and sandboxName it runs in, transport, state, stats, and tokens, each minted token with its secret. See MCP servers for the endpoint, tokens and traces.
Connectors
| Route | Scope | What it does |
|---|---|---|
GET /v1/connector-catalog | sandbox:read | Lists the catalog's tools, each with available, reason and added |
POST /v1/connectors/discover | sandbox:write, owner | Says how an MCP server's users would connect: { "mcpUrl" }. Nothing is kept. |
POST /v1/connectors | sandbox:write, owner | Adds a connector: { "catalog" }, { "mcpUrl", "auth"?, "app"? } or { "api": { "hosts" }, "app"? }, with id, name, selector, envVar and, for GitHub, githubApp optional |
GET /v1/connectors, GET /v1/connectors/<id> | sandbox:read | Lists connectors, or reads one, with its workspace account |
PATCH /v1/connectors/<id> | sandbox:write, owner | Changes its name, selector, envVar, app or githubApp |
DELETE /v1/connectors/<id> | sandbox:write, owner | Removes it with its workspace account and its users' connections |
POST /v1/connectors/<id>/account | sandbox:write, owner | Connects its workspace account: { "method": "key", "key" }, or sign_in or install, whose next step is in action, or device, whose code is in signIn |
POST /v1/connectors/<id>/account/callback | sandbox:write, owner | Finishes a sign-in that came back to the console: { "session", "confirm" } |
DELETE /v1/connectors/<id>/account | sandbox:write, owner | Disconnects its workspace account |
GET /v1/connectors/<id>/connections | sandbox:read | Its users' connections: { connections, next } |
GET /v1/users | sandbox:read | Your users, with their connections' statuses, their logins and this month's usage: { users, next } |
GET /v1/users/<user>, DELETE /v1/users/<user> | sandbox:read, sandbox:write | Reads a user with their connections, or removes them with their connections, logins and usage |
GET /v1/users/<user>/usage | sandbox:read | Their sandbox time, browser time and requests per UTC day: ?from= and ?to=, YYYY-MM-DD |
POST /v1/users/<user>/connections | sandbox:write | A connect link: { "connector", "returnUrl"? }, or the user's key: { "connector", "key" } |
POST /v1/users/<user>/connections/<connector>/sign-in | sandbox:write | The user's code for Codex (ChatGPT): { connection, signIn }, the code under way while it still works, else a new one; for a user who's signed in, a sign-in again |
GET /v1/users/<user>/connections/<connector>, DELETE … | sandbox:read, sandbox:write | Reads one connection, with its code's signIn, or disconnects the user |
GET, POST /v1/users/<user>/logins, GET, PATCH, DELETE …/logins/<login> | sandbox:read, sandbox:write | The user's logins: list ({ logins, nextCursor }), add, read, change and remove one |
…/logins/<login>/passkeys/<passkey>, …/session, …/live, …/live/done, …/check | sandbox:read, sandbox:write | Rename or remove a passkey; read, upload or remove a saved sign-in; open, finish or end a live view; check whether the site is still signed in |
POST /v1/users/<user>/logins/<login>/link, POST /v1/users/<user>/logins/link | sandbox:write | A link for the user to sign in to a site: { login, connectUrl } |
/v1/connectors/<id>/account/logins… | sandbox:read; changes an owner | Your workspace's logins, with the same routes as a user's but the link |
<user> is your ID for the user, URL-encoded: user_123, or auth0%7Cabc for auth0|abc. The two lists take ?limit= (1 to 100, 50 by default) and ?after=, from the answer's next.
curl https://api.caas.sh/v1/users/user_123/connections \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "connector": "linear", "returnUrl": "https://app.acme.com/settings" }'The answer's connectUrl is the link to send the user. A sandbox created with "user": "user_123" then uses their accounts for the connectors that reach it, and their logins in its browsers (see Logins). Codex (ChatGPT)'s users sign in with a code instead, which a Codex sandbox's start answers when it's needed; see Sign users in with a code.
curl https://api.caas.sh/v1/connectors \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "catalog": "openai", "selector": { "team": "ci" } }'
curl https://api.caas.sh/v1/connectors/openai/account \
-H "Authorization: Bearer $CAAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "method": "key", "key": "sk-..." }'A sandbox reaches a connector when it has every label of the connector's selector ({}, every sandbox). Sandboxes without a user then use the workspace's account; a sandbox's connectors lists what it got. See Connectors.
Billing
Billing routes need a key with Billing access, which only an owner can create. They work while the workspace is suspended.
| Route | Scope | What it does |
|---|---|---|
GET /v1/billing/balance | billing:read | { balanceMicroUsd, autoRecharge, card, topUps, entries, nextCursor }: the balance, auto-recharge, the card on file, recent top-ups with their receipts, and the balance's history, 60 entries a page |
GET /v1/billing/usage | billing:read | A month's usage report: ?month=2026-10, the current UTC month by default. Add &format=csv for the month as a CSV file. |
GET /v1/billing/auto-recharge | billing:read | { enabled, minimumMicroUsd, targetMicroUsd, active, failure } |
PUT /v1/billing/auto-recharge | billing:write | Changes any of enabled, minimumMicroUsd ($5 to $1,000) and targetMicroUsd (at least $5 more, up to $2,000) |
POST /v1/billing/top-ups | billing:write | Adds funds, { "amountMicroUsd": 25000000 } ($5 to $2,000 of credits, in whole cents), with a required idempotency key. The card is charged the credits; the top-up's feeMicroUsd is 0 (only a top-up made from October 20, 2026 until the fee's removal can carry a fee) |
curl "https://api.caas.sh/v1/billing/usage?month=2026-10" \
-H "Authorization: Bearer $CAAS_API_KEY"The usage report has a lines entry for each meter, with its quantity, unit and amountMicroUsd, the month's totalMicroUsd, a total for each UTC day in daily, and, while the month is under way, a projection for the whole month. The CSV has one row per UTC day and meter: day,meter,quantity,unit,rate_usd,amount_usd.
Adding funds charges the card on file and answers { "topUp": { … }, "url": null }; the balance goes up once the payment is confirmed. Without a card, or with "checkout": true, url is a Stripe Checkout page to finish the payment in. A declined card gets 402 with the failed topUp. See Billing and usage, and pricing for the rates.
Settings and the audit log
| Route | Scope | What it does |
|---|---|---|
GET /v1/settings | sandbox:read | { "timeZone": "UTC" } |
PATCH /v1/settings | sandbox:write | Sets the time zone, as an IANA name: { "timeZone": "Europe/Berlin" } |
GET /v1/audit-log | sandbox:read | The audit log, newest first: { records, nextCursor, retentionDays } |
GET /v1/audit-log takes these filters, each at most once:
| Parameter | Value |
|---|---|
action | An action, such as sandbox.stop, or a group, such as sandbox, command or mcp_server |
actor | A user's or API key's ID, a sandbox's ID (what its AI did), one of your users' IDs (what they did through a link), operator or system |
target | An ID, such as a sandbox's |
outcome | success or failure |
since, until | Times in milliseconds since the Unix epoch |
limit | 1 to 100 records; 50 by default |
cursor | The previous page's nextCursor |
Each record has its time (at), actor, action, target, outcome, HTTP status, requestId and a short detail, never a secret, a token or a command's text. Records are kept for 365 days. An API key never sees records about API keys, members or invitations, sees billing records only with billing:read, and sees a user who made a change by ID, without their email.