# Sandboxes

> Give an agent a remote computer to run code in. Create a sandbox from a ready-made image, run commands, open a terminal, move files, serve a dev server at its own URL and hibernate it for later.

Web page: https://kuiper.sh/docs/sandboxes/

## A computer for your agent

A sandbox is a remote computer for an agent: one isolated container, started from one of Kuiper's [sandbox images](https://kuiper.sh/docs/sandbox-images.md) (`kuiper/base`, `kuiper/coding`, `kuiper/data`…) in a size you pick. It lives until you release it or its timeout comes. Your agent runs commands in it, moves files in and out, and serves a dev server from it at a URL of its own.

Nothing carries over between sandboxes. Each one starts from its image and is destroyed when it ends, unless you [hibernate](#hibernate-a-sandbox) it to come back to later. Sandboxes run on machines of their own, under gVisor, apart from your units.

The API lives at `https://gateway.kuiper.sh/sandboxes` and takes a [gateway key](https://kuiper.sh/docs/gateway-keys.md) (`kgw_…`) that includes the `sandboxes` service.

## From a unit on Kuiper

Point your variables at the built-in `sandboxes` resource. Each running copy of the unit gets its own key and its region's gateway:

```sh
kuiper env set -p proxima -u agent \
  'SANDBOX_URL=${{sandboxes.BASE_URL}}' \
  'SANDBOX_KEY=${{sandboxes.API_KEY}}'
kuiper restart -p proxima -u agent
```

A Station unit's own sandbox children can run on these machines too, without changing Station. See [Station units](https://kuiper.sh/docs/station/#run-sandbox-children-on-kuiper).

## From anywhere else

Make a key for the project and use it the same way:

```sh
kuiper keys create laptop -p proxima --services sandboxes
export SANDBOX_URL=https://gateway.kuiper.sh/sandboxes
export SANDBOX_KEY=kgw_…
```

## Create a sandbox

```sh
curl -s "$SANDBOX_URL/v1/sandboxes" -H "Authorization: Bearer $SANDBOX_KEY" \
  -d '{"image": "kuiper/coding", "size": "small", "timeoutMs": 1800000,
       "env": {"NODE_ENV": "development"}, "ports": [5173]}'
```

```json
{
  "id": "6f1e0a52-8a8c-4b8e-9d3b-0d6c3c1f1a11",
  "status": "live",
  "image": "kuiper/coding",
  "size": "small", "cpu": 1, "memoryMiB": 2048, "diskMiB": 10240,
  "region": "eu-fsn",
  "createdAt": "2026-10-13T10:00:00Z",
  "expiresAt": "2026-10-13T10:30:00Z",
  "timeoutMs": 1800000,
  "ports": [{ "port": 5173, "public": false,
              "url": "https://5173-6f1e0a52-….eu-fsn.kiprsh.app/?kuiper_token=…" }]
}
```

Every field of the request is optional:

| Field | |
| --- | --- |
| `image` | A [sandbox image](https://kuiper.sh/docs/sandbox-images.md), `kuiper/<name>` or `kuiper/<name>:<version>`. The default is `kuiper/base`. |
| `size` | `small` (1 vCPU, 2 GiB of memory, 10 GiB of disk), `medium` (2 vCPU, 4 GiB, 20 GiB) or `large` (4 vCPU, 8 GiB, 40 GiB). The default is `small`. |
| `timeoutMs` | How long it lives: 5 minutes by default, 24 hours at most. You can [extend it](#keep-a-sandbox-longer) while it runs. |
| `inactivityTimeoutMs` | Release it after this long with no command, file transfer or port traffic. A command still running, or an open connection to a port, counts as activity. |
| `onInactive` | `release` (the default) or `hibernate`, to [hibernate](#hibernate-a-sandbox) it when it goes idle instead. |
| `env` | Variables every command gets, at most 200 and 64 KiB. They never appear in an answer. |
| `metadata` | Up to 50 strings of your own, kept with the sandbox and shown in its history. |
| `ports` | Ports to serve: `5173` (private) or `{"port": 3000, "public": true}`, at most 20. |

`/workspace` is where files go by convention, and it is what hibernation keeps.

From a terminal the CLI does the same, with a gateway key in `--key` or `KUIPER_GATEWAY_KEY`:

```sh
export KUIPER_GATEWAY_KEY=kgw_…
id=$(kuiper sandboxes create -p proxima --image kuiper/coding --timeout 30m --port 5173)
kuiper sandboxes exec "$id" -p proxima -- npm --version
kuiper sandboxes cp ./app.tar.gz "$id:/workspace/app.tar.gz" -p proxima
kuiper sandboxes release "$id" -p proxima
```

## Run commands

```sh
curl -s "$SANDBOX_URL/v1/sandboxes/$ID/exec" -H "Authorization: Bearer $SANDBOX_KEY" \
  -d '{"cmd": ["npm", "test"], "cwd": "/workspace", "timeoutMs": 600000}'
```

```json
{ "id": "3f…", "exitCode": 0, "stdout": "…", "stderr": "", "durationMs": 8123,
  "timedOut": false, "truncated": false }
```

- `cmd` is a program and its arguments, or one string run with `sh -c`. `cwd`, `env` (added to the sandbox's), `user`, `stdin` (a string) and `timeoutMs` are optional. A command still running at `timeoutMs` is killed and answers `timedOut: true`.
- **Streamed:** add `?stream=true`, or send `Accept: application/x-ndjson`, to get one JSON object per line as output comes: `{"type": "start", "id"}`, then `{"type": "stdout", "data"}` and `{"type": "stderr", "data"}`, then `{"type": "exit", "exitCode", "durationMs", "timedOut"}`. A whole answer keeps at most 4 MiB of each stream (`truncated: true` past that); a streamed one has no limit. `kuiper sandboxes exec` streams, and exits with the command's status.
- **In the background:** `"background": true` answers 202 with the command's `id` at once, and `GET …/exec/<id>` says whether it is still `running` and its `exitCode`. Use it for servers.

## Open a terminal

`GET /v1/sandboxes/<id>/exec/interactive` is a WebSocket to one command, with live input, on a terminal: a shell to work in, `python3 -i`, `vim` or an agent's own CLI. From your terminal:

```sh
kuiper sandboxes shell "$ID" -p proxima                 # bash, or sh
kuiper sandboxes shell "$ID" -p proxima -- python3 -i
```

Every key you press, Ctrl-C included, goes to the sandbox. The window's size follows yours, and the CLI exits with the command's status. With input that isn't a terminal, or `--no-tty`, the command gets plain pipes instead.

To build your own client, connect with the key in `Authorization: Bearer` (or `x-api-key`). A refusal (401, 404, or 409 while the sandbox starts or sleeps) is a plain HTTP answer, before the upgrade. Then:

1. Send a text frame with the command and its terminal. `cmd` is required; the rest is optional, as for [`POST …/exec`](#run-commands). `tty` is `true` unless you say otherwise, and `cols` and `rows` default to 80 and 24.

   ```json
   { "type": "start", "cmd": ["bash", "-l"], "cols": 120, "rows": 40,
     "cwd": "/workspace", "env": { "FOO": "1" }, "timeoutMs": 3600000, "tty": true }
   ```

2. The sandbox answers `{"type": "started", "id": "<exec id>"}`.
3. Then frames go both ways. **Binary frames start with one byte that names their stream**, followed by the bytes:

   | First byte | Direction | Stream |
   | --- | --- | --- |
   | `0` | to the sandbox | Standard input |
   | `1` | from the sandbox | Standard output (on a terminal, everything) |
   | `2` | from the sandbox | Standard error (only without a terminal) |

   **Text frames are JSON:**

   | Frame | Direction | |
   | --- | --- | --- |
   | `{"type": "stdin", "data": "ls\r"}` | to the sandbox | Input as text, for clients that can't send binary frames. |
   | `{"type": "resize", "cols": 132, "rows": 43}` | to the sandbox | The terminal's new size. |
   | `{"type": "eof"}` | to the sandbox | No more input. |
   | `{"type": "exit", "exitCode": 0, "durationMs": 81234, "timedOut": false}` | from the sandbox | The command ended. The connection closes next. |
   | `{"type": "error", "code": "…", "message": "…"}` | from the sandbox | It couldn't start. The connection closes next. |

   Ignore frame types you don't know, in both directions.

- **While the connection is open, the sandbox counts as in use**, so its inactivity timeout doesn't run out. Its `timeoutMs` still does.
- **Closing the connection hangs up the command**, like closing an SSH session. Use `tmux` or a [background command](#run-commands) for work that should outlive the connection.
- In a web page, `WebSocket` can't send an `Authorization` header. Connect from your own server, which holds the key, and relay to the page.

A minimal client in Node.js, with the `ws` package:

```js
import WebSocket from 'ws';

const url = `${process.env.SANDBOX_URL.replace(/^http/, 'ws')}/v1/sandboxes/${id}/exec/interactive`;
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${process.env.SANDBOX_KEY}` } });
ws.on('open', () => ws.send(JSON.stringify({ type: 'start', cmd: ['bash', '-l'], cols: 120, rows: 40 })));
ws.on('message', (data, isBinary) => {
  if (isBinary) return process.stdout.write(data.subarray(1)); // data[0] is the stream
  const m = JSON.parse(data);
  if (m.type === 'exit') process.exit(m.exitCode);
});
process.stdin.setRawMode(true);
process.stdin.on('data', (d) => ws.send(Buffer.concat([Buffer.from([0]), d])));
```

## Files

| Endpoint | |
| --- | --- |
| `PUT /v1/sandboxes/<id>/files?path=/workspace/a.txt` | Write the body to a file, making its parent directories. `&mode=0755` and `&user=` are optional. Up to 100 MB. |
| `GET /v1/sandboxes/<id>/files?path=/workspace/a.txt` | Read a file, up to 100 MB. |
| `GET /v1/sandboxes/<id>/files?path=/workspace&format=tar` | A directory as a tar archive, up to 1 GiB. |
| `GET /v1/sandboxes/<id>/files/list?path=/workspace` | `{path, entries: [{name, path, type, size, mode, modifiedAt}]}` |

Paths are absolute, without `.` or `..` segments. `kuiper sandboxes cp` and `kuiper sandboxes files` use these endpoints.

## Serve a port

A port the sandbox serves is reached at a name of its own, `https://<port>-<sandbox ID>.<region>.kiprsh.app/`, for HTTP and WebSockets alike. A Vite or Next.js dev server works in a browser, hot reload included. Ask for ports when you create the sandbox, or later:

```sh
curl -s "$SANDBOX_URL/v1/sandboxes/$ID/ports" -H "Authorization: Bearer $SANDBOX_KEY" \
  -d '{"port": 5173}'
curl -s -X DELETE "$SANDBOX_URL/v1/sandboxes/$ID/ports/5173" -H "Authorization: Bearer $SANDBOX_KEY"
kuiper sandboxes expose "$ID" 3000 --public -p proxima
```

- **Private ports** are the default. The URL the API answers with carries a token, `?kuiper_token=…`, bound to that sandbox, port and project and valid until the sandbox's timeout. The first visit trades the token for a cookie, so links and hot reload keep working. Programs can send it as an `x-kuiper-port-token` header instead. Without a valid token the port answers 401. `GET /v1/sandboxes/<id>` returns a fresh URL.
- **Public ports** answer anyone who has the URL. Requests to sandboxes the gateway hasn't seen yet are rate limited per client address, and past that limit the port answers **429** with `Retry-After`.
- **Your dev server sees the request as local** (`Host: localhost:<port>`), so its host checks pass without configuration. `X-Forwarded-Host` and `X-Forwarded-Proto` say how it was really reached. Bind the server to `0.0.0.0` (for Vite, `--host`), not `127.0.0.1`.
- The token and its cookie never reach the sandbox.

## Keep a sandbox longer

```sh
curl -s "$SANDBOX_URL/v1/sandboxes/$ID/timeout" -H "Authorization: Bearer $SANDBOX_KEY" \
  -d '{"timeoutMs": 1800000}'
kuiper sandboxes extend "$ID" --timeout 30m -p proxima
```

- `timeoutMs` counts from now: the sandbox lives at least that much longer. Its end only ever moves later, so calling it again with the same value works as a keep-alive.
- A sandbox's whole life stays within 24 hours. Past that the request is a 400 that says how much more it can still get.
- Only a live sandbox can be extended. A starting one answers 409 `not_live`, and a hibernated one 409 `hibernated`: resume it first.
- A private port's URL carries a token that lapses at the old end. A browser that already opened the port gets a new cookie on its next request; programs take the new URL from the answer.

## Disk

Each size has a disk quota, shown as `diskMiB`: 10 GiB for `small`, 20 for `medium` and 40 for `large`. It covers everything the sandbox writes, in `/workspace`, `/tmp` or packages it installs.

Depending on the machine, the quota is a hard limit or a measured one:

- **A hard limit:** a write past it fails with "No space left on device", as on a full disk. Delete files and carry on.
- **Measured:** the sandbox's disk use is measured every 30 seconds, and more often as it nears its quota. A sandbox found over it gets a warning in its `events`. If it is still over 30 seconds later, or reaches twice its quota at any point, it is released with `releaseReason: "disk_quota"`.

`GET /v1/sandboxes/<id>` shows `diskMiB`, `diskUsedMiB` where it is measured, and the `events`.

## Hibernate a sandbox

`POST /v1/sandboxes/<id>/hibernate` saves the sandbox's `/workspace` and its user's home directory, encrypted, then releases its container and stops billing it. `POST /v1/sandboxes/<id>/resume` brings it back with the same ID, environment, metadata, ports and files. `kuiper sandboxes hibernate` and `kuiper sandboxes resume` do the same.

- **Processes are not kept.** A resumed sandbox starts afresh on its files, so start its servers again. Files outside `/workspace` and the home directory, such as packages installed system-wide or `/tmp`, are not kept either.
- At most 1 GiB compressed is saved. A sandbox with more stays live, and the hibernate is refused.
- A hibernated sandbox can be resumed for 7 days (`hibernatedUntil`). After that it is released and its files are deleted. Releasing it deletes them at once.
- `timeoutMs` counts live time only, so a resumed sandbox lives what was left.
- While it sleeps, commands, files and ports answer 409 `hibernated`.

## Run them at your own provider

Sandboxes can also run at your organisation's own account with E2B, Daytona, Vercel Sandbox, Cloudflare or Railway. You use the same API, the same CLI and the same port URLs. The provider bills your account for them, and Kuiper charges nothing. Kuiper's own sandboxes stay the default: nothing changes until you choose a provider.

### Add a provider

An owner or admin adds the key once for the organisation, on the dashboard's **Providers** page or from the CLI. `--key -` reads the secret from standard input:

```sh
kuiper providers add e2b --key -
kuiper providers add daytona --key -
kuiper providers add vercel-sandbox --key - --field team_id=team_… --field project_id=prj_…
kuiper providers add railway --key - --field environment_id=…
kuiper providers add cloudflare --name cf --key - \
  --field bridge_url=https://kuiper-sandbox.<you>.workers.dev
kuiper providers test e2b
```

| Kind | What it needs |
| --- | --- |
| `e2b` | An E2B API key. |
| `daytona` | A Daytona API key. `region` is optional (Daytona's target, such as `us` or `eu`). |
| `vercel-sandbox` | A Vercel access token, the team (`team_id`) and the project (`project_id`) the sandboxes belong to. |
| `cloudflare` | Your [bridge Worker's](#cloudflare-deploy-the-bridge) URL (`bridge_url`) and the key you gave it. |
| `railway` | A Railway token and the environment (`environment_id`) the sandboxes live in. `token_kind` is `bearer` (an account or workspace token, the default) or `project` (a project token). |

`kuiper providers test NAME` makes one cheap call to the provider with the key and says whether it worked.

### Choose it

For a whole project, on the project's **Sandboxes** page (owners and admins) or with the CLI:

```sh
kuiper sandboxes provider e2b -p proxima
kuiper sandboxes provider -p proxima
kuiper sandboxes provider kuiper -p proxima
```

The first command sends the project's new sandboxes to that key, the second says where they go now, and the third goes back to Kuiper's own. For one sandbox, name the key when you create it: `"provider": "e2b"` in the request, or `kuiper sandboxes create --provider e2b`. `"provider": "kuiper"` asks for Kuiper's own.

A sandbox at a provider answers with `"provider"` (the key's name), `"providerKind"` and `"billedBy": "provider"`. Its ID is Kuiper's. If the project's key is removed, creating a sandbox answers **409** `provider_missing` until you choose another.

### What works where

| | E2B | Daytona | Vercel | Cloudflare | Railway |
| --- | --- | --- | --- | --- | --- |
| `image` | an E2B template (default `base`) | a Daytona snapshot | an image in Vercel's registry | an image your bridge's `wrangler.jsonc` names (default `sandbox`) | a Railway template or checkpoint name |
| `size` | no: the template sets it | yes | yes | yes | yes |
| Commands, streamed, with stdin | yes | yes | yes | yes | yes |
| In the background | yes (no exit status) | yes | yes | yes | yes |
| Terminal / plain pipes | yes / yes | yes / no | yes / no | yes / yes | no / yes |
| `user` | any | no | `root` | any | no |
| Files, a directory as tar | yes | yes, no tar | yes, no tar | yes | yes |
| Ports | any, any time | any, any time | at create only, up to 15 | any, any time | at create only, up to 10 |
| Keep longer, hibernate, resume | yes | yes | yes | yes | yes |

- Kuiper's own images (`kuiper/…`) don't run elsewhere: asking for one is a **400** that says what the provider takes instead. Leave `image` out for the provider's default.
- What a provider can't do is a **400** `not_supported` that names the provider, such as "ports must be declared when the sandbox is made on Vercel".
- `inactivityTimeoutMs` and `onInactive` are for Kuiper's own sandboxes only.
- `recipe` is Railway's: a list of shell steps Railway runs to build the sandbox's template, instead of `image`. `kuiper sandboxes create --provider rail --recipe 'apt-get install -y git'`.
- `timeoutMs` works as for Kuiper's own. Kuiper releases the sandbox at your provider when it comes, even if your provider would keep it longer. A hibernated one is released after 7 days.
- Hibernating keeps files, as for Kuiper's own: E2B pauses the sandbox, Daytona and Vercel stop it, Cloudflare takes a snapshot, and Railway saves a checkpoint and makes a new sandbox from it when you resume. The ID you use stays the same.
- Your provider key, and the tokens a provider gives for a sandbox's ports, stay at Kuiper's gateway. Your agents only ever see Kuiper's IDs, port URLs and port tokens.

### Cost and limits at a provider

Your provider bills you. Kuiper shows each sandbox's time and size on the **Sandboxes** page, marked as billed by the provider, without a cost. Budgets and the limit of 10 live sandboxes don't apply to them. The limit of 600 requests a minute per key does.

### Cloudflare: deploy the bridge

Cloudflare has no API that starts a sandbox and runs a command in it, so you run a small Worker in your own account, and Kuiper calls it. You deploy it yourself: Kuiper never gets a token that can change your Workers. It needs the Workers Paid plan, Node.js 20 or later, and Docker running where you deploy.

1. Get Kuiper's bridge Worker (`deploy/byok/cloudflare-sandbox` in Kuiper's repository) and install its tools:

   ```sh
   cd deploy/byok/cloudflare-sandbox
   npm install
   ```

2. Sign in and deploy. The first deploy builds the sandbox image and can take several minutes. It prints the Worker's URL.

   ```sh
   npx wrangler login
   npx wrangler deploy
   ```

3. Make a long random key and give it to the Worker:

   ```sh
   openssl rand -hex 32
   npx wrangler secret put BRIDGE_KEY
   ```

4. Add it to Kuiper with that URL and key, and test it:

   ```sh
   kuiper providers add cloudflare --name cf --key - \
     --field bridge_url=https://kuiper-sandbox.<you>.workers.dev
   kuiper providers test cf
   ```

Add images to `images` in its `wrangler.jsonc` and deploy again; Kuiper's `image` names them. Cloudflare stops a container that has been idle for 6 hours; the bridge sets that timer again every hour while the sandbox lives.

### Not available yet

- Modal. It has no public API that Kuiper can call, only its own SDKs.
- Kuiper deploying the Cloudflare bridge for you.

## Every endpoint

| Endpoint | |
| --- | --- |
| `POST /v1/sandboxes` | [Create one](#create-a-sandbox). |
| `GET /v1/sandboxes?status=live\|hibernated\|released\|ended\|all` | The project's sandboxes, live ones first. Ended ones page with `cursor`. |
| `GET /v1/sandboxes/<id>` | One sandbox, live or not. |
| `POST /v1/sandboxes/<id>/release`, or `DELETE /v1/sandboxes/<id>` | Release it. A hibernated one's files are deleted. |
| `POST /v1/sandboxes/release` | Release all of the project's sandboxes, hibernated ones included. |
| `POST /v1/sandboxes/<id>/timeout` | [Keep it longer](#keep-a-sandbox-longer). |
| `POST /v1/sandboxes/<id>/exec`, `GET …/exec/<exec>` | [Run commands](#run-commands). |
| `GET /v1/sandboxes/<id>/exec/interactive` (WebSocket) | [Open a terminal](#open-a-terminal). |
| `GET` or `PUT /v1/sandboxes/<id>/files`, `GET …/files/list` | [Files](#files). |
| `POST /v1/sandboxes/<id>/ports`, `DELETE …/ports/<port>` | [Serve a port](#serve-a-port). |
| `POST /v1/sandboxes/<id>/hibernate`, `…/resume` | [Hibernate](#hibernate-a-sandbox). |

A sandbox belongs to the project of the key that made it. Any key of that project can use it, and every other key gets a 404.

`status` is `starting`, `live`, `hibernating`, `hibernated`, `released` or `failed`. An ended sandbox's `releaseReason` is one of `user_requested`, `timeout`, `inactivity_timeout`, `exited` (its main process stopped or ran out of memory), `startup_failed`, `node_lost`, `hibernated`, `hibernation_expired`, `budget_exceeded`, `stopped_by_admin` or `disk_quota`. At [your own provider](#run-them-at-your-own-provider) it can also be `provider_ended` (the provider had ended it) or `provider_key_removed` (its key was removed, so Kuiper can't reach it).

Errors are `{"error": "…", "code": "…"}`. The codes are `bad_request`, `not_found`, `not_live` and `hibernated` (409), `no_capacity` (503: try again, or a smaller size), `budget_exceeded` (402), `too_many_sandboxes` and `rate_limited` (429), and `sandboxes_stopped` (403). At your own provider there are also `not_supported` (400: the provider can't do that), `provider_missing` (409), `provider_error` (400 or 502, with the provider's words), `provider_refused` (502: it refused the key), `provider_quota` (402), `provider_rate_limited` (429) and `provider_unavailable` (502).

## Cost and limits

A sandbox is billed by the second from the moment it is live until it is released or hibernated: its size's vCPUs at the price per vCPU-hour, plus its memory at the price per GiB-hour. A hibernated sandbox costs nothing. Both show on the **Usage** page, and each sandbox's cost on the project's **Sandboxes** page (`kuiper sandboxes list`). See [Usage and billing](https://kuiper.sh/docs/usage.md).

- **Budgets** cover sandboxes as they do browsers. A used-up `block` budget refuses new sandboxes and resumes with **402** `budget_exceeded`. See [Budgets and limits](https://kuiper.sh/docs/budgets.md).
- **Concurrent sandboxes:** an organisation holds at most 10 live sandboxes at once. Past that, creating or resuming one is a **429** `too_many_sandboxes`. Hibernated ones don't count.
- **Requests:** each key may make 600 requests a minute to the sandbox API, commands and files included. Traffic to exposed ports isn't counted. Past that, **429** `rate_limited` with `Retry-After`.

### Not available yet

- MicroVM sandboxes, and pausing and resuming running processes.
- Containers a unit starts through its own Docker socket run on the unit's machine, not here. See [Station units](https://kuiper.sh/docs/station.md).
