# Sandboxes

A sandbox is an isolated virtual machine for an agent or any other program. It runs one command when it first starts, then any command you send it. It keeps `/workspace` between stops and restarts, stops by itself when idle and starts again when work arrives. It can serve one HTTP port at its own URL, and it takes snapshots you can restore.

This guide covers a sandbox's whole life in the console, the CLI and the API. The CLI examples assume you've installed and configured `caas` (see [The CLI](/docs/cli)).

## Create a sandbox

### In the console

1. Open **Sandboxes** and choose **New sandbox**. (The button shows for a workspace's owners and operators.)
2. Under **1. Choose an image**, pick **Default** or **Your image** (see [Images](#images)), and fill in what the sandbox runs (see [Choose what it runs](#choose-what-it-runs)).
3. Under **2. Configure**, optionally enter a **Name**, and pick a **Size** (see [Sizes](#sizes)). The estimate beside the form shows what the size costs.
4. In **Allowed outbound hosts**, list the hosts the sandbox may reach, one per line. Everything else is blocked.
5. Keep **Keep state between restarts** checked to keep `/workspace` across stops and restarts, or uncheck it to start fresh every time.
6. Choose **Create sandbox**.

The sandbox's page opens. Its status moves from **Starting** to **Running**, and then its command runs.

### With the CLI

`caas run` creates a sandbox, waits for its command to finish and prints the command's output:

```sh
caas run --name build-agent --size l -- node --version
```

Put `--` before the command whenever its arguments start with `--`. The options:

| Option | What it does |
| --- | --- |
| `--from .` | Uploads your Git repository's committed files, unpacked into `/workspace` (see [An uploaded archive](#an-uploaded-archive)) |
| `--image IMAGE` | Runs your own image instead of the Default one |
| `--name NAME` | Names the sandbox |
| `--size SIZE` | `xs`, `s`, `m` (the default), `l`, `xl`, `2xl`, or `custom` with `--vcpu`, `--memory` and `--disk` |
| `--egress-host HOST` | Allows the sandbox to reach a host; repeat it for each host |
| `--expose PORT` | Serves a port at the sandbox's URL; add `--public` to open it to anyone |
| `--idle-stop MINUTES` | Stops it after this many idle minutes, 5 to 240 (15 by default) |
| `--wake-on-request` | Lets requests start it even after you stop it yourself |
| `--ephemeral` | Starts it fresh every time, keeping nothing between restarts |
| `--detach` | Returns as soon as the sandbox exists, without waiting for its command |

`caas run` exits with an error when the command fails. With `--detach`, it prints the sandbox, with its ID, as soon as it exists; check the command later with `caas sandboxes command <sandbox-id> initial-agent-command`.

When the command starts with `npx`, `caas run` also allows `registry.npmjs.org`.

### With the API

Send the sandbox you want to `POST /v1/tenants/<tenant-id>/sandboxes` with an API key. Your Tenant ID and API keys are in the console's **Settings**.

```sh
export CAAS_TENANT=<tenant-id>
export CAAS_API_KEY=<api-key>

curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "kind": "agent",
    "name": "build-agent",
    "size": "l",
    "source": { "type": "stdio", "command": ["node", "--version"] },
    "egressHosts": ["api.github.com"]
  }'
```

The answer, `201 Created`, is the new sandbox with its `id` and `"status": "starting"`. The `Idempotency-Key` header (8 to 128 characters) is required: if you retry with the same key, you get the same sandbox, never a second one.

| Field | Value |
| --- | --- |
| `kind` | `"agent"` for a sandbox. (`"mcp"` deploys an MCP server; see [MCP servers](/docs/mcp).) |
| `source` | What it runs; see [Choose what it runs](#choose-what-it-runs). Required. |
| `size` | `"xs"`, `"s"`, `"m"`, `"l"`, `"xl"` or `"2xl"`, or a custom size such as `{ "vcpu": 2, "memoryGib": 8, "diskGb": 16 }`. Defaults to `"m"`. |
| `name` | Optional; see [Names](#names). |
| `egressHosts` | Up to 32 hosts the sandbox may reach. |
| `persistent` | `false` to start fresh on every start. Defaults to `true`. |
| `idleStopMinutes` | 5 to 240. Defaults to 15. |
| `wakeOnRequest` | `true` to let requests start a sandbox you stopped. Defaults to `false`. |
| `expose` | `{ "port": 3000, "public": false }` turns on the sandbox's URL. |
| `snapshotTime` | The nightly snapshot's time as `"HH:MM"`, or `null` for none. Defaults to `"02:00"` for a sandbox that keeps state. |

`GET /v1/tenants/<tenant-id>/sandboxes` lists your sandboxes with their status, and `GET /v1/tenants/<tenant-id>/sandboxes/<sandbox-id>` returns one. See [The API](/docs/api) for keys and their scopes.

## Images

### The Default image

The Default image is Alpine Linux with bash, coreutils, Node and Docker. It runs on any size. It doesn't include your project's dependencies or coding agents such as Claude Code: install what you need in a command, or build your own image. To install Alpine packages with `apk`, allow `dl-cdn.alpinelinux.org`.

### Your own image

You can run any `linux/amd64` image you've pushed to your workspace's namespace of the registry, `registry.caas.sh/<tenant-id>/NAME`. [Images and the registry](/docs/images) shows how to push one.

- A sandbox on your own image needs size M or larger, or a custom size with at least 4 GiB of memory and 8 GB of disk.
- It needs a command, which replaces the image's entrypoint and command.
- The command, every later command and file transfer run inside the image's container, with `/workspace` mounted. The image needs `/bin/sh`, `base64`, `tar` and `gzip`, which caas.sh uses to move files and take snapshots.
- In the console, pick **Your image** and enter the image by digest, `registry.caas.sh/<tenant-id>/NAME@sha256:…` (`docker push` prints the digest).
- The CLI also takes a tag and pins it to its digest for you:

  ```sh
  caas run --image agent:1.2.0 -- node agent.js
  ```

- The API takes the digest form in `source`: `{ "type": "image", "reference": "registry.caas.sh/<tenant-id>/agent@sha256:…", "command": ["node", "agent.js"] }`.

## Sizes

A size sets the vCPU, memory and disk the sandbox gets:

| Size | vCPU | Memory | Disk |
| --- | --- | --- | --- |
| XS | 1/16 | 256 MiB | 2 GB |
| S | 1/4 | 1 GiB | 4 GB |
| M (default) | 1/2 | 4 GiB | 8 GB |
| L | 1 | 6 GiB | 12 GB |
| XL | 2 | 8 GiB | 16 GB |
| 2XL | 4 | 12 GiB | 20 GB |

A **Custom** size takes whole numbers: 1 to 4 vCPU, at least 3 GiB of memory per vCPU and at most 12 GiB, and 2 to 20 GB of disk. In the console, pick **Custom** and fill in **vCPU**, **Memory (GiB)** and **Disk (GB)**. With the CLI:

```sh
caas run --size custom --vcpu 2 --memory 8 --disk 16 -- node task.js
```

A sandbox keeps the size it was created with. While it runs, it bills for its full memory and disk, and for vCPU only while it computes. See [pricing](/pricing.html) for the rates.

## Names

A name is optional. It has up to 63 lowercase letters, digits and hyphens, starts and ends with a letter or digit, and is unique among the workspace's sandboxes and MCP servers. The console shows it in place of the ID, and `caas sandboxes list` prints it beside the ID. CLI and API commands take the sandbox's ID.

## Choose what it runs

Every sandbox runs a command once, after it first starts: the agent's initial run. The command runs in `/workspace`, as one executable with its arguments and no shell. Its time limit is two hours. To use shell features, run a shell: `sh -c 'node build.js && node test.js'`. The API takes the command as a list of 1 to 32 arguments.

On the Default image, the files the command works on come from one of three places.

### A command alone

Enter only the **Command**. In the API, the source is `{ "type": "stdio", "command": ["node", "--version"] }`.

### A Git repository

In the console, enter a **Repository** (such as `github.com/your-org/agent`) and, unless it's the default branch, a **Branch**. The repository is cloned into `/workspace` before the command runs. In the API:

```json
{ "type": "git", "url": "https://github.com/your-org/agent", "ref": "main", "command": ["node", "task.js"] }
```

The URL is an HTTPS URL with no username, password or port in it. `ref` is a branch, tag or commit, and defaults to the default branch. The clone happens in a fresh workspace: on the first start, or on every start of a sandbox that doesn't keep state. While it sets up, the sandbox may reach the repository's host even if that host isn't in its allowed outbound hosts.

### An uploaded archive

With the CLI, `--from .` packs your Git repository's committed files and uploads them:

```sh
caas run node task.js --from .
```

Run it at the repository's root. It refuses a worktree with uncommitted changes or untracked files, and tracked files whose names look sensitive (such as `.env` or `id_rsa`). Ignored files are never uploaded.

In the console, when the command runs a file of yours, such as `./run.sh` or `node task.js`, the form asks for a **Source archive**: a `.tar.gz` of your files.

With the API, upload a gzipped tar of up to 250 MiB first, then pass its `uploadId` in the source:

```sh
tar -czf source.tar.gz -C my-project .

curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/uploads \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/gzip" \
  --data-binary @source.tar.gz
```

```json
{ "type": "stdio", "command": ["node", "task.js"], "uploadId": "<upload-id>" }
```

The archive is unpacked into `/workspace` on the first start. An upload is kept for seven days, so a sandbox started from one must keep state between restarts.

## Run commands

You can run more commands in a sandbox at any time. Each one runs in `/workspace` as one executable with its arguments, with a time limit from 1 second to 2 hours. A command sent to a sandbox that stopped while idle starts it first.

### In the console

On the sandbox's page:

- **Inspect initial run** shows the initial command and its output.
- **Run a command** takes a **Command** and a **Time limit** (1 minute to 2 hours; 10 minutes by default). Quote arguments that contain spaces. The result shows each stream's output, or a download link when it's long, with **Refresh** and, while it runs, **Cancel**.

### With the CLI

```sh
caas sandboxes exec <sandbox-id> -- node --version
caas sandboxes exec <sandbox-id> --timeout-ms 3600000 -- sh -c 'node build.js && node test.js'
```

`exec` waits for the command, prints its output, and exits with an error when it fails. `--timeout-ms` takes 1000 to 7200000 (10 minutes by default). With `--detach`, it returns at once with the command's ID:

```sh
caas sandboxes command <sandbox-id> <command-id>
caas sandboxes cancel-command <sandbox-id> <command-id>
```

### With the API

Send the command with an `Idempotency-Key` of 8 to 128 letters, digits, `-` and `_`. The key becomes the command's ID, and resending it never runs the command twice.

```sh
curl https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/commands \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: run-tests-0001" \
  -d '{ "command": ["node", "--version"], "timeoutMs": 600000 }'
```

Instead of `command`, you can send `script`, a shell script of up to 131,071 bytes that runs as `/bin/sh -c`. Then read the command, waiting up to 25 seconds for it to end:

```sh
curl "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/commands/run-tests-0001?waitMs=25000" \
  -H "Authorization: Bearer $CAAS_API_KEY"
```

The record has the command's `state` (`submitting`, `running`, `succeeded`, `failed`, `timed_out`, `canceled` or `lost`), `exitCode`, and `stdout` and `stderr`. When a stream is short, its `base64` field holds all of it; otherwise download it from `…/commands/<command-id>/stdout` or `…/stderr`. `DELETE …/commands/<command-id>` cancels a running command.

Command records are kept for seven days, at most 1,000 per sandbox. Stopping a sandbox cancels its running commands.

## Read and write files

The API reads, writes and deletes single files under `/workspace` or `/tmp` (but not `/workspace/.caas`, which belongs to caas.sh), up to 100 MiB each:

```sh
# Write a file, creating its directories
curl -X PUT "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/files?path=/workspace/config.json" \
  -H "Authorization: Bearer $CAAS_API_KEY" \
  --data-binary @config.json

# Read it; add &tail=4096 for only its last 4,096 bytes
curl "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/files?path=/workspace/config.json" \
  -H "Authorization: Bearer $CAAS_API_KEY" -o config.json

# Delete it
curl -X DELETE "https://api.caas.sh/v1/tenants/$CAAS_TENANT/sandboxes/$SANDBOX_ID/files?path=/workspace/config.json" \
  -H "Authorization: Bearer $CAAS_API_KEY"
```

A write replaces the whole file. Like a command, a transfer starts a sandbox that stopped while idle; if the sandbox is still starting, you get `503`: try again shortly. Only `/workspace` is kept between restarts: `/tmp` starts empty every time. The bytes you read, like downloaded command output, bill as egress.

## Stop and start

Stop a sandbox with **Stop** on its page or in its row's menu in **Sandboxes**, `caas sandboxes stop <sandbox-id>`, or `POST …/sandboxes/<sandbox-id>/stop`. Start it with **Start**, `caas sandboxes start <sandbox-id>`, or `POST …/start`.

A sandbox that keeps state saves `/workspace` as it stops and gets it back when it starts. A stopped sandbox bills for no vCPU, memory or disk, only for storing its snapshots.

### Idle stop

A running sandbox stops by itself after 15 minutes without activity. Commands and file transfers, a command still running, and requests to its URL or its MCP endpoint all count as activity. Set the period, from 5 to 240 minutes, when you create the sandbox: `--idle-stop 60` with the CLI, or `"idleStopMinutes": 60` in the API.

### Wake on request

A sandbox that stopped while idle starts again by itself when a command, a file transfer or a request to its URL arrives. A sandbox you stopped yourself stays stopped until you start it, unless you created it with `--wake-on-request` (`"wakeOnRequest": true`).

A sandbox also starts only while the workspace has a card on file and a balance above $0. At $0, its sandboxes stop until funds arrive; see [Billing and usage](/docs/billing). After three failed starts in a row, start the sandbox yourself.

### Keep state, or start fresh

By default a sandbox keeps `/workspace` between stops and restarts, with automatic and nightly snapshots. A sandbox created without **Keep state between restarts**, with `--ephemeral`, or with `"persistent": false` starts with a fresh workspace every time and takes no automatic or nightly snapshots. You can still take snapshots of it yourself and restore one. A sandbox started from an uploaded archive always keeps state.

## Sandbox URLs

A sandbox can serve one HTTP port at its own address, `https://<label>.caa.sh`. The label is an opaque name that reveals neither your workspace nor the sandbox's ID.

### Turn on the URL

- In the console, use the sandbox page's **Sandbox URL** card: enter the **Port** your server listens on and choose **Turn on**.
- With the CLI, pass `--expose PORT` to `caas run`, or turn it on later:

  ```sh
  caas run --expose 8080 --from . -- node server.js
  caas sandboxes expose <sandbox-id> 8080
  ```

- With the API, pass `"expose": { "port": 8080 }` when you create the sandbox, or send `PUT …/sandboxes/<sandbox-id>/url` with `{ "port": 8080, "public": false }`.

The port is from 1024 to 65535, other than 8788, which caas.sh uses itself. The console's **Details** card shows the URL, and so does `caas sandboxes inspect <sandbox-id>` (as `url`). In a sandbox on your own image, the port is the one your image's process listens on.

### Protected and public URLs

A URL is protected unless you make it public. A request to a protected URL needs an access token; without one it gets `401` and never starts the sandbox.

- In the console, open the URL from **Details**: it opens in a new tab with a 12-hour session.
- Get a token with the CLI (60 to 86,400 seconds; 3,600 by default), and send it as a bearer token:

  ```sh
  caas sandboxes url-token <sandbox-id> --ttl 600
  curl -H "Authorization: Bearer <token>" https://<label>.caa.sh/
  ```

- With the API, `POST …/sandboxes/<sandbox-id>/url-tokens` with `{ "ttlSeconds": 600 }` returns the `token`, its `expiresAt`, the `url` and a `sessionUrl`. To open the URL in a browser, submit a form that POSTs `token` (and optionally `next`, a path such as `/dashboard`) to the `sessionUrl`: the browser keeps a session until the token expires.

To let anyone with the link open it, check **Public: anyone with the link** in the console, add `--public` to `--expose` or `caas sandboxes expose`, or send `"public": true`.

### Turn off the URL

Choose **Turn off** on the **Sandbox URL** card, run `caas sandboxes expose <sandbox-id> --off`, or send `DELETE …/sandboxes/<sandbox-id>/url`. Every token and session made for the URL stops working at once, and the URL answers `404` until you turn it on again.

### What to know

- A request to the URL counts as activity, and starts a sandbox that stopped while idle. A visitor to a sandbox you stopped yourself gets `409`, unless it wakes on request.
- The URL's responses bill as egress.
- WebSocket connections aren't supported yet.
- Paths under `/__caas/` are caas.sh's and never reach your server.

## Snapshots

A snapshot is a saved copy of a sandbox's `/workspace`. A sandbox has three kinds:

| Kind | Taken | Kept |
| --- | --- | --- |
| Manual | When you take one, with a name or called `manual` | Until you delete it |
| Scheduled | Every night, at 02:00 in the workspace's time zone unless you change it | The newest 7 |
| Automatic | About every hour while the sandbox runs, and each time it stops | The newest two and the oldest |

In a sandbox that keeps state, the snapshot it would start from if it lost its workspace is its recovery point, marked `current`: it's kept whatever its kind. Snapshot storage bills at $0.025 per GB-month. Taking and restoring snapshots is included.

### Take a snapshot

Snapshots are taken of a running sandbox. Choose **Take snapshot** on the sandbox's page and enter a name or leave it empty, or:

```sh
caas sandboxes snapshot <sandbox-id> --name before-upgrade
caas sandboxes snapshots <sandbox-id>
```

`caas sandboxes snapshots` lists each snapshot's ID, name, kind, time and size, the bytes stored and the nightly schedule. In the API, send `POST …/sandboxes/<sandbox-id>/snapshot` with an optional `{ "name": "before-upgrade" }`, and list them with `GET …/snapshots`. A name follows the rules for [sandbox names](#names). Archiving a large workspace takes a while; the snapshot is listed once it's done.

### Set the nightly snapshot

A new sandbox takes its nightly snapshot at 02:00 in the workspace's time zone, which is UTC until you set one under **Settings**, **Workspace** (or with `caas settings --time-zone Europe/Berlin`). The time zone is an IANA name, such as `Europe/Berlin`. Changing it affects sandboxes created afterwards.

To move one sandbox's nightly snapshot, or turn it off, use the **Nightly snapshot** card on its page, or:

```sh
caas sandboxes schedule <sandbox-id> --at 03:30 --time-zone Europe/Berlin
caas sandboxes schedule <sandbox-id> --off
```

In the API, send `PUT …/sandboxes/<sandbox-id>/snapshot-schedule` with `{ "time": "03:30", "timeZone": "Europe/Berlin" }`, or `{ "time": null }` to turn it off.

### Restore a snapshot

1. Stop the sandbox. A sandbox that keeps state takes a snapshot as it stops, so you can come back to what you have now.
2. Choose **Restore** beside the snapshot on the sandbox's page, or run:

   ```sh
   caas sandboxes restore <sandbox-id> --snapshot <snapshot-id>
   ```

   In the API, send `POST …/sandboxes/<sandbox-id>/restore` with `{ "snapshotId": "<snapshot-id>" }`.

The sandbox starts with that snapshot as its workspace. Newer snapshots stay, so you can restore one of them later. If the snapshot can't start, the sandbox stays stopped with its previous recovery point.

### Delete a snapshot

Choose **Delete** beside the snapshot, run `caas sandboxes delete-snapshot <sandbox-id> <snapshot-id>`, or send `DELETE …/sandboxes/<sandbox-id>/snapshots/<snapshot-id>`. You can't delete the current recovery point of a sandbox that keeps state until a newer snapshot exists.

## Delete a sandbox

Choose **Delete** in the sandbox's row menu in **Sandboxes**, run `caas sandboxes delete <sandbox-id>`, or send `DELETE /v1/tenants/<tenant-id>/sandboxes/<sandbox-id>`. Deleting a sandbox removes its workspace, snapshots, logs and URL for good, frees its share of the quota, and frees its name for a new sandbox.

## Quotas and limits

A workspace may hold 16 vCPU and 64 GiB of memory across all its sandboxes and MCP servers that aren't deleted, stopped ones included, counted at their sizes, plus the widest stage of each workflow run that hasn't ended. A sandbox that would pass the quota is refused with `quota_exceeded`: delete a sandbox, or wait for a workflow run to end.

| Limit | Value |
| --- | --- |
| Allowed outbound hosts set at creation | 32 |
| Command | 1 to 32 arguments of up to 512 characters each |
| Shell script (API) | 131,071 bytes |
| Command time limit | 1 second to 2 hours |
| Command records | 7 days, at most 1,000 per sandbox |
| File transfer | 100 MiB per file |
| Uploaded archive | 250 MiB, kept 7 days |
| Idle stop | 5 to 240 minutes |
| URL access token | 60 seconds to 24 hours |

## Logs and activity

A sandbox's page in the console shows what it did:

- **Runtime output**: its runtime log, with each start, stop and failure and its reason, the end of its setup's output, and how the initial command ended.
- **Log archives**: the runtime log as downloadable JSONL files, kept for 90 days.
- **Recent events**: its lifecycle events.
- **Outbound activity**: the newest requests it tried to make, and whether its network policy allowed them.

The same, with the CLI:

```sh
caas sandboxes logs <sandbox-id>
caas sandboxes log-archives <sandbox-id>
caas sandboxes log-archive <sandbox-id> <archive-id> > runtime.jsonl
caas sandboxes events <sandbox-id>
caas sandboxes egress <sandbox-id>
```

`log-archives` and `egress` print a cursor for older pages: pass it back with `--cursor`. The outbound log keeps the newest 1,000 allowed and 1,000 blocked requests for seven days, without query strings, headers or bodies. In the API, read `GET …/sandboxes/<sandbox-id>/logs`, `/log-archives`, `/log-archives/<archive-id>`, `/events` and `/egress`.

A command's output isn't in the runtime log: read it from the command (see [Run commands](#run-commands)). To change which hosts a sandbox may reach, or to add your API keys to its requests, see [Credentials and outbound access](/docs/credentials).
