# How Do I Find and Clean Up Orphaned Sandboxes?

> Label every sandbox at create with an owner and run id, then list by that label (GET /sandboxes?labels=…), check state and lastActivityAt, and delete with a dry run first. A TTL and idle pause stop forgotten sandboxes but keep their storage. Deleting removes automatic snapshots and hosted URLs; named snapshots stay.

Source: https://sandboxer.com/guides/clean-up-orphaned-sandboxes. Updated 2026-10-27.

**Short answer:** Label every sandbox at create with an owner and run id, then list by that label (GET /sandboxes?labels=…), check state and lastActivityAt, and delete with a dry run first. A TTL and idle pause stop forgotten sandboxes but keep their storage. Deleting removes automatic snapshots and hosted URLs; named snapshots stay.

An orphan is a sandbox whose creator is gone: an agent that lost its context, a job killed mid-run, a `finally` block that never ran. You can only clean up what you can identify, so the work starts at create time. This page covers tagging, finding, the backstops that limit the damage, a cleanup script we ran on prod, and exactly what a delete removes. For the cost levers (TTLs, scoped keys, account limits and alerts), see [cap what agent sandboxes can cost](/guides/cap-what-agent-sandboxes-can-cost).

## Tag every sandbox when you create it

Give each sandbox two handles: a `name` for people and `labels` for code. Labels are set through the REST API and the [MCP server](/docs/mcp); the CLI and SDKs don't have a labels flag yet ([labels docs](/docs/api#labels)). Use one label that says which app or agent owns it and one that names the run, so a crashed run's sandboxes are one query away.

```bash
curl -sS -X POST https://sandboxer.com/api/v1/sandboxes \
  -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
  -d '{"type":"small","name":"orphan-demo-1","ttlSeconds":1800,
       "labels":{"owner":"myapp-251bf53f","job":"run-1"}}'
```

```json
{"id":"sb_xqxi33l1ve","name":"orphan-demo-1","state":"provisioning","labels":{"job":"run-1","owner":"myapp-251bf53f"},"archiveAfter":"2026-10-27T11:08:40.832437Z"}
```

That came back `202` (trimmed with `jq`). If a sandbox was made with `sbx new`, add labels afterwards with `PATCH`. A `PATCH` replaces the whole label set, so send every label you want to keep:

```bash
sbx new --name orphan-demo-2 --type small --ttl 1800
curl -sS -X PATCH https://sandboxer.com/api/v1/sandboxes/sb_y6da8q95pz \
  -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" \
  -d '{"labels":{"owner":"myapp-251bf53f","job":"run-2"}}'
```

```json
{"id":"sb_y6da8q95pz","labels":{"job":"run-2","owner":"myapp-251bf53f"}}
```

Make the owner value unique to your app. If several services or people share an account, a generic value like `owner=agent` will match their sandboxes too.

## Find them

`GET /sandboxes` filters on the server by `labels` and `state`, and pages with `limit` and `cursor` ([pagination](/docs/api#pagination)). We stopped the second sandbox first, so the list shows one of each:

```bash
curl -sS -G https://sandboxer.com/api/v1/sandboxes -H "Authorization: Bearer $SANDBOXER_API_KEY" \
  --data-urlencode "labels=owner=myapp-251bf53f" \
  | jq -c '.sandboxes[] | {id,name,state,createdBy,lastActivityAt,archiveAfter}'
```

```json
{"id":"sb_y6da8q95pz","name":"orphan-demo-2","state":"archived","createdBy":"…","lastActivityAt":"2026-10-27T10:40:16.659896Z","archiveAfter":null}
{"id":"sb_xqxi33l1ve","name":"orphan-demo-1","state":"ready","createdBy":"…","lastActivityAt":"2026-10-27T10:40:38Z","archiveAfter":"2026-10-27T11:08:40.832437Z"}
```

Variations we ran:

| Query | What it matched |
| - | - |
| `labels=owner=…` | Both sandboxes, running and stopped |
| `labels=owner=…` and `state=archived` | Only the stopped one (its `stop.reason` was `user`) |
| `labels={"owner":"…","job":"run-1"}` (JSON form) | Only `sb_xqxi33l1ve`: every label must match |
| `labels=owner` (no value) | `400 invalid_labels` |

The fields that tell you whether a sandbox is abandoned:

| Field | What it tells you |
| - | - |
| `labels` | Which app and run made it. Filtered on the server |
| `name` | A display name. Not unique and not filterable on the server; match it with `jq` |
| `state` | `ready` and similar are billing compute. `archived` is stopped: no compute, but it holds storage |
| `lastActivityAt` | The last command, file call, hosted request or start. Recorded in batches, so it can lag by a little |
| `archiveAfter` | When auto-stop fires. On a running sandbox, `null` means auto-stop is off. Ours read `null` after we stopped it, so check `state` first |
| `createdBy`, `createdById` | Which member created it. Useful with an organization wallet, where the list also includes the organization's sandboxes |

There is no "created by this API key" field in the list. `GET /api-keys/{id}/usage` lists the sandboxes a key created ([API keys](/docs/api-keys)).

Without labels, fall back to names. `sbx list` shows only running sandboxes by default, so a stopped orphan is invisible unless you pass `--all`:

```bash
sbx list --all --json | jq -c '.sandboxes[]
  | select(.name // "" | startswith("orphan-demo-")) | {id,name,state}'
```

```json
{"id":"sb_y6da8q95pz","name":"orphan-demo-2","state":"archived"}
{"id":"sb_xqxi33l1ve","name":"orphan-demo-1","state":"ready"}
```

## The backstops: TTL and idle pause

Two settings stop a forgotten sandbox without anyone noticing it. Neither deletes it.

| Backstop | Default | What it does to an orphan | What it leaves behind |
| - | - | - | - |
| Auto-stop (`ttlSeconds`, deadline in `archiveAfter`) | 1 hour; up to 30 days, or off with `--no-auto-stop` | Stops it at the deadline | Disk and memory, counted against your included storage |
| Idle pause (`idleStopMinutes`) | 15 to 120 minutes by plan | Pauses it when nothing touches it | The same, and the next request wakes it |

So an orphan with both left on costs compute for at most its TTL, then sits stopped. The one to hunt is a running sandbox with `archiveAfter: null`: auto-stop is off, and only idle pause can stop it. A dev server polled by a health check never goes idle. [Idle auto-pause](/guides/idle-auto-pause-and-wake-on-use) lists what counts as activity, and [sandbox cost tables](/guides/sandbox-cost-tables) shows how much storage each plan includes. When storage is full, new sandboxes answer `402 storage_limit_reached`, which is how stopped orphans eventually hurt.

## A cleanup script with a dry run

This script deletes the sandboxes that carry a label set. It is a dry run unless you pass `--delete`, it requires a label filter, and `--idle-minutes` keeps sandboxes that were active recently, using `lastActivityAt` rather than creation time. It collects every page before deleting, so the cursor doesn't shift under it. It needs `curl` and `jq` and reads `SANDBOXER_API_KEY` from the environment.

```bash
#!/usr/bin/env bash
# Delete the sandboxes that carry a label, after a dry run.
#   ./cleanup.sh owner=nightly-agent                      # dry run: print what would go
#   ./cleanup.sh owner=nightly-agent --idle-minutes 120   # only those quiet for 2 h
#   ./cleanup.sh owner=nightly-agent --delete             # really delete
set -euo pipefail
API=${SANDBOXER_API_URL:-https://sandboxer.com/api/v1}
LABELS=${1:?usage: cleanup.sh k=v[,k=v] [--idle-minutes N] [--delete]}; shift
IDLE=0; DELETE=0
while [ $# -gt 0 ]; do
  case $1 in
    --idle-minutes) IDLE=$2; shift 2 ;;
    --delete) DELETE=1; shift ;;
    *) echo "unknown flag: $1" >&2; exit 2 ;;
  esac
done
auth=(-H "Authorization: Bearer $SANDBOXER_API_KEY")
cutoff=$(( $(date +%s) - IDLE * 60 ))

# 1. Collect every match first (deleting while paging would shift the cursor).
targets=(); cursor=""
while :; do
  page=$(curl -sS --fail-with-body -G "$API/sandboxes" "${auth[@]}" \
    --data-urlencode "labels=$LABELS" --data-urlencode "limit=200" \
    ${cursor:+--data-urlencode "cursor=$cursor"})
  while IFS=$'\t' read -r id name state last; do
    [ -n "$id" ] || continue
    printf '%s\t%s\t%s\tlast activity %s\n' "$id" "$name" "$state" "$last"
    targets+=("$id")
  done < <(jq -r --argjson cutoff "$cutoff" '.sandboxes[]
      | (.lastActivityAt // .createdAt) as $t
      | select(($t | sub("\\.[0-9]+"; "") | fromdate) <= $cutoff)
      | [.id, (.name // ""), .state, $t] | @tsv' <<<"$page")
  [ "$(jq -r .pageInfo.hasMore <<<"$page")" = true ] || break
  cursor=$(jq -r .pageInfo.nextCursor <<<"$page")
done

echo "${#targets[@]} sandbox(es) match labels=$LABELS, idle >= ${IDLE} min"
if [ "$DELETE" != 1 ]; then echo "dry run: nothing deleted (add --delete)"; exit 0; fi

# 2. Delete. The API wants the id echoed back in a header as confirmation.
for id in "${targets[@]}"; do
  curl -sS --fail-with-body -X DELETE "$API/sandboxes/$id" "${auth[@]}" \
    -H "X-Sandboxer-Confirm-Delete: $id" | jq -c '{id: .operation.targetId, op: .operation.id, status: .operation.status}'
done
```

We ran it on October 27, 2026 against our two labeled sandboxes, about two minutes after creating them. First two dry runs:

```bash
./cleanup.sh owner=myapp-251bf53f --idle-minutes 60
./cleanup.sh owner=myapp-251bf53f
```

```text
0 sandbox(es) match labels=owner=myapp-251bf53f, idle >= 60 min
dry run: nothing deleted (add --delete)
sb_y6da8q95pz	orphan-demo-2	archived	last activity 2026-10-27T10:40:16.659896Z
sb_xqxi33l1ve	orphan-demo-1	ready	last activity 2026-10-27T10:40:38Z
2 sandbox(es) match labels=owner=myapp-251bf53f, idle >= 0 min
dry run: nothing deleted (add --delete)
```

Then the real run:

```bash
./cleanup.sh owner=myapp-251bf53f --delete
```

```text
…
2 sandbox(es) match labels=owner=myapp-251bf53f, idle >= 0 min
{"id":"sb_y6da8q95pz","op":"bdop_4ed678ff9a4d98e6fcd67b174dad9083","status":"pending"}
{"id":"sb_xqxi33l1ve","op":"bdop_c2dd198e6821c56d8f4f89730f773d91","status":"pending"}
```

Both deletion operations read `completed` when we checked them a few seconds later with `sbx deletion status <op>`, about 0.3 seconds after they were requested. A third dry run matched 0 sandboxes.

Without the `X-Sandboxer-Confirm-Delete` header, the API refuses with `409 confirmation_required` ([limits and errors](/docs/limits)). The CLI form of one delete is `sbx delete <id> --yes`.

## What deletion removes, and what it keeps

Before deleting, we put a private hosted URL on `sb_xqxi33l1ve` (a Python web server on port 3000) and saved `sb_y6da8q95pz` as a named snapshot. Then we checked everything after the script ran:

| Thing | Before | After the delete |
| - | - | - |
| `GET /sandboxes/{id}` | `200` | `404` for both; `sbx info` exits 4 with `not_found` |
| Hosted URL on port 3000 | `401` without the token, `200` with it | `404`, with the token and with the session cookie |
| Automatic snapshots (`sbx snapshots <id>`) | One, kind `base` | The list answers `404`: the sandbox is gone, and so are the snapshots only it used |
| Named snapshot saved from it | `ready` | Still `ready`, `sourceSandboxId` pointing at the deleted id |
| Label query | 2 matches | 0 matches |

The [CLI docs](/docs/cli) put the snapshot rule this way: delete removes the sandbox and the snapshots only it uses. Snapshots that a fork or a named snapshot still depends on are kept until nothing uses them ([snapshots](/docs/snapshots)). Named snapshots are kept until you delete them with `sbx snapshot rm <name>`. That makes them the other place orphans hide: they count toward included storage and are filterable by the same `labels` on `GET /named-snapshots`. We removed ours with `sbx snapshot rm` and confirmed it then answered `snapshot_not_found`.

There's no undo. If you might want the data, stop the sandbox instead and delete it later.

## FAQ

### Does auto-stop delete a sandbox?

No. It stops it at `archiveAfter` with memory and disk kept. A stopped sandbox bills no compute but counts toward your included storage until you delete it.

### Can I filter sandboxes by name on the server?

No. `GET /sandboxes` filters by `labels` and `state`. Filter names client-side with `jq`, and remember `sbx list` needs `--all` to show stopped sandboxes.

### How do I keep my cleanup from deleting someone else's sandboxes?

Filter by a label value only your app sets, run the dry run first, and read its list. Never delete by age alone on an account that other people or services share.

### Can an agent's key delete only the sandboxes it created?

Yes. A key with `sandbox.delete-own` instead of `sandbox.delete` gets `403` on sandboxes it didn't create. The `ci` preset includes it ([API keys](/docs/api-keys)).

### What happens to a sandbox's hosted URLs when it's deleted?

They stop answering. Ours returned `404` right after the delete, with or without the port's token.

## Related

- [Cap what agent sandboxes can cost](/guides/cap-what-agent-sandboxes-can-cost)
- [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call)
- [Does a cloud sandbox pause itself when idle?](/guides/idle-auto-pause-and-wake-on-use)
- [API: labels](/docs/api#labels)
