---
name: run-cloud-sandboxes
description: Operate and debug run.cloud microVM sandboxes with the CLI or TypeScript SDK. Use for creating isolated compute, running commands, moving files, managing snapshots or images, exposing ports, using SSH or desktop automation, attaching secrets, sizing resources, or cleaning up sandboxes. Also use to investigate a sandbox that is down, stuck, hung, unreachable, or not responding, to find out why one stopped or was destroyed, or to trace a sandbox from a log line by tag.
---

# Operate run.cloud Sandboxes

Use the `runcloud` CLI for terminal and interactive workflows. Use
`@run-cloud/sdk` for TypeScript applications, CI, and agent code.

## Authenticate

- Install the CLI with `npm install -g runcloud`.
- Use `runcloud login` for an interactive browser handoff. Use
  `runcloud login --manual` when a local callback cannot open.
- In CI, set `RUN_CLOUD_API_KEY`. `RUN_CLOUD_API_TOKEN` is an equivalent alias.
- Set `RUN_CLOUD_API_URL` only to override the production default
  `https://api.run.cloud`.
- Never print, commit, or place credentials in a skill file.
- Treat signed desktop and tunnel URLs as bearer secrets.
- Require Node.js 20 or newer for the CLI and TypeScript SDK.

Inspect account and organization usage before starting metered work:

```bash
runcloud account --json
```

## Choose the Interface

- Prefer CLI commands with `--json` for shell automation.
- Prefer the TypeScript SDK when code needs streaming command output, binary
  file transfer, retry-safe creation, or short-lived public tunnels.
- Inspect `runcloud sandbox --help`, a subcommand's `--help`, or installed SDK
  types before using a method not documented here.
- Use the `sandbox` noun. The older `box` commands are deprecated aliases.

## Run a CLI Lifecycle

Create a sandbox, capture its ID, run a command, and destroy it:

```bash
SANDBOX_ID=$(runcloud sandbox create \
  --image runcloud/agent-base \
  --timeout 900 \
  --json | jq -r '.id')

trap 'runcloud sandbox rm "$SANDBOX_ID" >/dev/null 2>&1 || true' EXIT

runcloud sandbox get "$SANDBOX_ID" --json
runcloud sandbox exec "$SANDBOX_ID" "npm install && npm test"
```

The main lifecycle commands are:

- `runcloud sandbox create`
- `runcloud sandbox list [--state <state>] [--name <name>]`
- `runcloud sandbox get <id>`
- `runcloud sandbox exec <id> <cmd...>`
- `runcloud sandbox shell <id>`
- `runcloud sandbox pause|resume <id>`
- `runcloud sandbox rename <id> <name>` — relabel an existing sandbox; pair with
  `list --name` to find a sandbox again instead of creating a second one
- `runcloud sandbox logs <id> [--lines <n>]`
- `runcloud sandbox metrics <id> [--range <range>] [--watch]`
- `runcloud sandbox rm <id>`

Create accepts `--image`, `--region`, `--name`, `--org`, `--cpu`, `--memory`,
`--disk`, `--idle-pause`, `--timeout`, `--persistent`, `--expose`, secret
selectors, `--no-wait`, and `--json`.

The default reservation is 0.125 vCPU and 128 MiB when CPU and memory are
omitted. Set finite timeouts for unattended work. Use `--timeout 0` only when a
persistent workload is intentional.

CLI `exec` runs through `/bin/sh -c` and returns the guest command's exit code.
A paused sandbox must be resumed before `exec`; `shell` resumes it
automatically.

## Use the TypeScript SDK

Install the SDK:

```bash
npm install @run-cloud/sdk
```

Use an idempotency key when a job runner may retry creation, check non-zero
exit codes explicitly, and always destroy metered resources:

```ts
import { Client } from "@run-cloud/sdk";

const cloud = new Client();
const sandbox = await cloud.sandboxes.create({
  image: "runcloud/agent-base",
  cpu: 1,
  memory: 1024,
  timeoutSeconds: 900,
  idempotencyKey: process.env.CI_JOB_ID,
});

try {
  const result = await cloud.sandboxes.exec(
    sandbox.id,
    ["npm", "test"],
    {
      onStdout: (chunk) => process.stdout.write(chunk),
      onStderr: (chunk) => process.stderr.write(chunk),
    },
  );
  if (result.exitCode !== 0) {
    throw new Error(`tests failed with exit code ${result.exitCode}`);
  }
} finally {
  await cloud.sandboxes.destroy(sandbox.id);
}
```

The TypeScript sandbox surface is:

- `cloud.sandboxes`: `create`, `list`, `get`, `exec`, `readFile`,
  `writeFile`, `openTunnel`, `closeTunnel`, `setTimeout`, `snapshot`,
  `destroy`, and the `delete` alias
- `cloud.snapshots`: `list`, `restore`, `delete`
- `cloud.account()` and `cloud.usage({ orgId? })`

A string passed to SDK `exec` runs through `/bin/sh -c`; an argv array executes
directly. A non-zero guest exit code is returned rather than thrown. Use
`cwd`, `env`, `timeoutSeconds`, `onStdout`, `onStderr`, and `signal` as needed.

Use `readFile` and `writeFile` for binary-safe SDK file transfer. For
interactive terminal workflows, configure SSH with `runcloud sandbox setup-ssh`
and inspect `runcloud sandbox ssh`, `cp`, and `code` help before use.

## Snapshot and Restore

Snapshot a prepared filesystem and restore it into fresh sandboxes:

```bash
runcloud sandbox snapshot create "$SANDBOX_ID" --label deps-installed --json
runcloud sandbox snapshot list --json
runcloud sandbox restore <snapshot-id> --json
runcloud sandbox snapshot rm <snapshot-id>
```

The SDK equivalents are `cloud.sandboxes.snapshot`,
`cloud.snapshots.restore`, `cloud.snapshots.list`, and
`cloud.snapshots.delete`.

A restore creates a new billed sandbox with a new ID. One snapshot can fan out
to parallel workers, but every restored sandbox counts toward concurrency and
must be destroyed. A restored sandbox inherits no secrets; attach only the
secrets it needs.

## Use Images

Use reusable images when every sandbox needs the same base tools:

```bash
runcloud image create my-agent --dockerfile ./Dockerfile
runcloud image list --json
runcloud sandbox create --image my-agent --json
runcloud image refresh my-agent
```

Inspect `runcloud image --help` for current build-source options. Do not invent
image methods on the TypeScript SDK.

## Expose a Service

Choose one exposure model deliberately:

- For a stable hostname, create with
  `runcloud sandbox create --name <name> --expose <port> --persistent`, or use
  `runcloud sandbox expose <id> --port <port>`.
- For a short-lived random URL in TypeScript, call
  `cloud.sandboxes.openTunnel(id, port, { ttlSeconds })`, then
  `closeTunnel(id, tunnel.id)`.

An SDK tunnel does not make the sandbox persistent or disable idle pause. Do
not log its URL. Revocation can take effect shortly after `closeTunnel`
returns; stop the guest service or destroy the sandbox when access must end
immediately.

## Handle Secrets Safely

- Create secret groups from `--from-dotenv`, `--from-json`, `--stdin`, a file,
  or a hidden prompt. Never pass a secret value as a command argument.
- Attach only the required groups or names with repeatable `--secret-group` and
  `--secret`. Later selectors win on name collisions; `--env` is applied last.
- Use `--no-secrets` to state explicitly that a new sandbox needs none.
- Three verbs, because two of them change things:
  - `runcloud sandbox secrets <id>` reads. Names, where each one resolved from,
    and a digest of its value.
  - `runcloud sandbox secrets set <id> ...` replaces. It is a
    full replacement, not a merge: the sandbox ends up holding exactly what
    that one command names.
  - `runcloud sandbox secrets revoke <id>` takes everything back.
- A listing of `Unknown` is not `Nothing`. The inventory is recorded when a
  delivery is accepted, so a sandbox created before inventories existed has no
  record — its secrets still work. Only `Nothing` means the sandbox is empty.
- Values never come back, from any command. The digest exists so you can tell
  whether the value in a sandbox is still the one its group holds; it is not
  reversible and there is no masked form.
- Snapshots do not contain secrets, so a restored sandbox starts with none.

Inspect `runcloud secret-group --help` and `runcloud secrets --help` for the
current non-plaintext input forms.

## Operate Desktop Sandboxes

For a compatible desktop image, use `runcloud sandbox desktop <id>` to open its
signed browser desktop. The CLI also provides `screenshot`, `click`, `type`,
and `key` subcommands for explicit pixel-coordinate automation. Keep signed
desktop URLs private and inspect each subcommand's help before automation.

## Tag Sandboxes, and Find Them Again

Tags are arbitrary `key=value` metadata on a lease. They are how an operator
gets from a symptom back to the machine that caused it, and they **outlive the
sandbox** — so "which sandbox ran this?" is still answerable after the VM is
gone, which is usually when you are asking.

Tag on create, or on an existing sandbox:

```bash
runcloud sandbox create --image runcloud/agent-base   # then:
runcloud sandbox tag sbx_123 newly.run=run-42 owner=qasim
runcloud sandbox tag sbx_123 owner=          # empty value removes the key
```

Find by tag — repeatable, and a sandbox must carry every one:

```bash
runcloud sandbox list --tag newly.run=run-42 --json
runcloud sandbox shell --tag newly.run=run-42   # resolves, then opens a shell
```

`shell --tag` refuses to guess: no match and several matches are both errors,
and the ambiguous case lists what it found. Opening a shell on the wrong
machine is worse than being told to be precise.

Reserved keys the platform sets itself — do not overwrite them:

| key | meaning |
| --- | --- |
| `newly.kind` | what created it (`ci`, …) |
| `newly.run` | the CI run id |
| `newly.environment` | the CI environment id |
| `newly.session` / `newly.role` | Newly session association, for simulators |

### Debugging when you have only a log line

The control plane logs a sandbox's tags on **create** and **destroy**, so the
first step needs no API token and no database access:

```bash
gcloud logging read \
  'resource.labels.service_name="cp-api-dev" AND jsonPayload.message="run.cloud sandbox created"' \
  --limit 20 --freshness=6h --format=json
```

Then either query as a normal user (`runcloud sandbox list --tag …`), or use the
ops endpoint, which is org-agnostic and audited — the right tool when you do not
know whose org it was:

```
GET /diagnose/sandbox?tag=newly.run:run-42
```

It is gated by an ops OIDC token, not a user credential: mint one by
impersonating the `cp-diagnose-ops-<env>` service account, which every engineer
can do. Destroyed sandboxes are included on purpose.

**Credentials.** The CLI defaults to **prod**. For dev, set both:

```bash
export RUN_CLOUD_API_URL=https://api-dev.newly.app
export RUN_CLOUD_API_TOKEN=...   # RUN_CLOUD_DEV_API_TOKEN in secrets/dev.yaml
```

Key names do not match the env vars, and `sops` needs
`gcloud auth application-default login` rather than plain `gcloud auth login` —
see the Secrets section of the root `CLAUDE.md`/`AGENTS.md` before concluding
you lack access.

## What the Signals Mean When a Sandbox Misbehaves

`state` describes the microVM lease, not the workload inside it. A sandbox whose
process died an hour ago still reads `running`, so `running` on its own never
means healthy.

The console log is one file per sandbox carrying boot output, everything `exec`
printed, and lifecycle markers, interleaved in the order they happened.
`runcloud sandbox logs <id>` serves it, and it survives destroy: the host renames
the file rather than deleting it, so a post-mortem read works on a sandbox that
no longer exists.

Three limits that change what you can conclude from it:

- A destroyed sandbox's console gets removed after 14 days (2 weeks), so a
  post-mortem has a deadline. A running or paused sandbox's log is never removed,
  however long it has sat quiet.
- It is capped at 10 MiB and trimmed to the newest 5 MiB, so on a chatty workload
  early output is gone, not merely paged out. An empty-looking start is a trim,
  not proof the process printed nothing.
- `systemd-run` output goes to the journal, not the console, so those units never
  appear here at all. `journalctl -u <unit>` reaches it, but only from inside a
  running sandbox, so that output is unrecoverable once the sandbox is destroyed
  and needs a resume on a paused one. A workload whose output has to survive a
  post-mortem should write to stdout rather than a unit.

Two details about reading the file: the `created` marker is appended to a shell
prompt line rather than starting one, so match `--- sandbox` anywhere in the line
rather than anchoring to the start, and boot output dwarfs everything else, so
reach for the tail before the head.

### What the metrics actually settle

`runcloud sandbox metrics <id> --json` carries typed counters, and they are how
you refute a theory instead of arguing about it. The natural wrong answer on a
128 MiB sandbox is always "it OOMed":

| field | what a zero proves |
| --- | --- |
| `memory_oom_kills`, `memory_oom_events` | nothing was OOM-killed, so a dead process exited on its own |
| `cpu_throttled_periods`, `cpu_throttled_millicores` | the CPU reservation was not starving it |
| lifetime `network` totals | at ~0 bytes in, no request ever arrived, so the fault is upstream of the guest |

Each has a `*_valid` companion; when that is false the counter is unavailable
rather than zero, and a zero you cannot trust proves nothing.

`memory_bytes` is the exception and it misleads. It is measured host-side and
includes page cache, so it routinely reads several times the sandbox's `mem_mb`
reservation. That is not a leak and not an impending OOM. Believe
`memory_oom_kills` over it, every time.

### Lifecycle markers

The host writes a marker into the console on every transition:

```
--- sandbox paused: timeout-sweep at 2026-08-05T11:02:11Z ---
```

The events are `created`, `paused`, `resumed`, `stopped`, and `destroyed`. The
token after the colon is the control plane's actor, written verbatim so it can be
matched rather than parsed; the sentence around it is not a contract. Only stops
carry a reason, so `created` and `resumed` appear without one.

Read the **event** before the actor. A paused sandbox still exists and its disk is
intact; a destroyed one is gone. Telling someone their work is lost when it is
warm-parked and resumable is the most expensive mistake available here.

| actor | ends up | what it means |
| --- | --- | --- |
| `timeout-sweep` | paused | hit its `--timeout` lifetime cap. Not a crash, and the work is still there |
| `idle-sweep` | paused | idle-paused after `--idle-pause` seconds. Not a failure at all |
| `retention-sweep` | destroyed | the 48h parked window expired and it was reaped for real |
| `pause` `resume` `stop` `archive` `restore` | as asked | a caller requested exactly this |
| `api` `api-force` | destroyed | a caller destroyed it. `api-force` bypassed a wedged guest |
| `health-sweep` | interrupted | the platform judged it unhealthy |
| `host-state-reconcile` | paused | the host had already parked it and the control plane caught up. The pause happened earlier than this event says |
| `host-reconcile` `reap-sweep` | destroyed | the platform reclaimed it |
| `boot-failure` `placement-timeout` `request-timeout` `scheduler` | never ran | capacity or image, not the workload |
| `image-built` `image-build-failure` | varies | an async image build finished or failed |
| `startup-recover` `volume-lease-invalid` `replica-fork-resume-failure` | varies | platform recovery paths |

**Getting a parked sandbox back.** `timeout-sweep` and `idle-sweep` leave a full
snapshot, so `runcloud sandbox resume <id>` restores it warm. You have 48h before
`retention-sweep` destroys it for real. Resuming restarts the lifetime window from
now rather than clearing it, so a sandbox parked by `timeout-sweep` will park again
after the same interval; raise the cap first with the SDK's
`cloud.sandboxes.setTimeout(id, seconds)`, which has no CLI equivalent.

**The `--timeout` trap.** `--timeout` is a wall-clock lifetime cap, wholly separate
from `--idle-pause`. It fires on a fully busy sandbox, and `--persistent` /
`--idle-pause 0` do **not** hold it off, despite `--persistent` reading as "never
pause when idle". Default is 300s, ceiling 24h.

A sandbox that failed before it ever reached a host has no console, because the
file is created at boot. Those carry a `Last error` row on
`runcloud sandbox get` instead, which is then the only record of the cause.

### "Not reachable" is usually not a network problem

Exposure is a separate record, not a field on the sandbox, so `sandbox get` omits
it entirely when none exists rather than showing it as empty. Absence of a
`Hostname` row therefore means **no public route exists**, not that the CLI
declined to print one. `runcloud sandbox domain list <id>` states it outright and
names the fix. A sandbox created without `--expose` is unreachable from outside
however healthy the workload is.

When a route does exist and the port still refuses, the guest is usually bound to
`127.0.0.1` rather than `0.0.0.0`, which is invisible from outside and looks
identical to a dead app.

## Guardrails

- Destroy every sandbox created during a task unless the user explicitly asks
  to keep it. Also remove unused snapshots, tunnels, and public hostnames.
- Use `try/finally` or a shell trap around every metered lifecycle.
- Check `exitCode`; do not treat a completed SDK `exec` call as success by
  itself.
- Do not expose API credentials, secret values, signed desktop URLs, or tunnel
  URLs in logs, screenshots, PR comments, or chat output.
- Do not claim that CLI-only lifecycle, image, secret-group, desktop, or stable
  hostname commands are TypeScript SDK methods.
- Do not overwrite a `newly.*` tag; the platform sets those and support reads
  them. Add your own key instead.
