kuiper Docs

Agent services

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.

On this page

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 (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 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 (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:

Terminal
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.

From anywhere else#

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

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

Create a sandbox#

Terminal
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
imageA sandbox image, kuiper/<name> or kuiper/<name>:<version>. The default is kuiper/base.
sizesmall (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.
timeoutMsHow long it lives: 5 minutes by default, 24 hours at most. You can extend it while it runs.
inactivityTimeoutMsRelease 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.
onInactiverelease (the default) or hibernate, to hibernate it when it goes idle instead.
envVariables every command gets, at most 200 and 64 KiB. They never appear in an answer.
metadataUp to 50 strings of your own, kept with the sandbox and shown in its history.
portsPorts 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:

Terminal
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#

Terminal
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:

Terminal
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. 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 byteDirectionStream
    0to the sandboxStandard input
    1from the sandboxStandard output (on a terminal, everything)
    2from the sandboxStandard error (only without a terminal)

    Text frames are JSON:

    FrameDirection
    {"type": "stdin", "data": "ls\r"}to the sandboxInput as text, for clients that can't send binary frames.
    {"type": "resize", "cols": 132, "rows": 43}to the sandboxThe terminal's new size.
    {"type": "eof"}to the sandboxNo more input.
    {"type": "exit", "exitCode": 0, "durationMs": 81234, "timedOut": false}from the sandboxThe command ended. The connection closes next.
    {"type": "error", "code": "…", "message": "…"}from the sandboxIt 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 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.txtWrite 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.txtRead a file, up to 100 MB.
GET /v1/sandboxes/<id>/files?path=/workspace&format=tarA 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:

Terminal
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#

Terminal
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:

Terminal
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
KindWhat it needs
e2bAn E2B API key.
daytonaA Daytona API key. region is optional (Daytona's target, such as us or eu).
vercel-sandboxA Vercel access token, the team (team_id) and the project (project_id) the sandboxes belong to.
cloudflareYour bridge Worker's URL (bridge_url) and the key you gave it.
railwayA 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:

Terminal
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#

E2BDaytonaVercelCloudflareRailway
imagean E2B template (default base)a Daytona snapshotan image in Vercel's registryan image your bridge's wrangler.jsonc names (default sandbox)a Railway template or checkpoint name
sizeno: the template sets ityesyesyesyes
Commands, streamed, with stdinyesyesyesyesyes
In the backgroundyes (no exit status)yesyesyesyes
Terminal / plain pipesyes / yesyes / noyes / noyes / yesno / yes
useranynorootanyno
Files, a directory as taryesyes, no taryes, no taryesyes
Portsany, any timeany, any timeat create only, up to 15any, any timeat create only, up to 10
Keep longer, hibernate, resumeyesyesyesyesyes
  • 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:

    Terminal
    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.

    Terminal
    npx wrangler login
    npx wrangler deploy
  3. Make a long random key and give it to the Worker:

    Terminal
    openssl rand -hex 32
    npx wrangler secret put BRIDGE_KEY
  4. Add it to Kuiper with that URL and key, and test it:

    Terminal
    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/sandboxesCreate one.
GET /v1/sandboxes?status=live|hibernated|released|ended|allThe 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/releaseRelease all of the project's sandboxes, hibernated ones included.
POST /v1/sandboxes/<id>/timeoutKeep it longer.
POST /v1/sandboxes/<id>/exec, GET …/exec/<exec>Run commands.
GET /v1/sandboxes/<id>/exec/interactive (WebSocket)Open a terminal.
GET or PUT /v1/sandboxes/<id>/files, GET …/files/listFiles.
POST /v1/sandboxes/<id>/ports, DELETE …/ports/<port>Serve a port.
POST /v1/sandboxes/<id>/hibernate, …/resumeHibernate.

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 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.

  • 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.
  • 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.