# Track Which Agent Sandbox Is Working on Which PR

> Give each agent its own sandbox and label it at create with repo, branch, task and agent. When the agent opens its pull request, add a pr label. Then one API query finds the sandbox for any PR, sbx history and sbx ps show what it's doing, and merged PRs are cleaned up by label.

Source: https://sandboxer.com/guides/track-which-agent-sandbox-is-working-on-which-pr. Updated 2026-11-01.

**Short answer:** Give each agent its own sandbox and label it at create with repo, branch, task and agent. When the agent opens its pull request, add a pr label. Then one API query finds the sandbox for any PR, sbx history and sbx ps show what it's doing, and merged PRs are cleaned up by label.

Names are for people and they aren't unique. Labels are key-value pairs the API filters on, so they make the link between a sandbox and a pull request queryable ([labels docs](/docs/api#labels)). This page is the bookkeeping layer. Starting the agents is covered in [run several coding agents in parallel](/guides/run-coding-agents-in-parallel), and reading one agent's full trail in [see what an AI agent did in its sandbox](/guides/see-what-an-ai-agent-did-in-its-sandbox).

Everything below ran on prod on 2026-11-01 with CLI build `d944553c` and three `small` sandboxes. No model ran. Each sandbox stood in for one agent: it made a branch in a test repo, committed, and left a process running (a dev server, a test loop, a long task). Output is real, trimmed with `…`.

## The label scheme

| Label | Set when | Example | Why |
| - | - | - | - |
| `fleet` | Create | `acme-agents` | Scopes every query to your sandboxes. Pick a value unique to your setup, since others on the account may label too |
| `repo` | Create | `acme-web` | Which repository the agent works in |
| `branch` | Create | `fix-login-redirect` | The branch it pushes |
| `task` | Create | `ORI-101` | The ticket it's working on |
| `agent` | Create | `claude-code` | Which agent runs inside |
| `pr` | The agent opens its PR | `412` | The lookup key from the PR side |
| `status` | The PR merges or closes | `merged` | What cleanup selects on |

Values are strings, so store the PR number as `"412"`. Don't put a private preview URL in a label: its token is a secret, and every list call returns labels.

## 1. Label each sandbox at create

The CLI and SDKs don't have a labels flag yet, so create through the REST API. `name` still helps when you read `sbx list` by eye.

```bash
api() { curl -sS -H "Authorization: Bearer $SANDBOXER_API_KEY" -H "Content-Type: application/json" "$@"; }
B=https://sandboxer.com/api/v1

api -X POST $B/sandboxes -d '{"type":"small","name":"agent-1","ttlSeconds":3600,
  "labels":{"fleet":"acme-agents","repo":"acme-web","branch":"fix-login-redirect",
            "task":"ORI-101","agent":"claude-code"}}' | jq -c '.sandbox | {id,name,state,labels}'
```

```json
{"id":"sb_uq23ghng33","name":"aeo-trackpr-1","state":"provisioning","labels":{"agent":"claude-code","branch":"fix-login-redirect","fleet":"aeo-trackpr","repo":"acme-web","task":"ORI-101"}}
{"id":"sb_gduy6z4b90","name":"aeo-trackpr-2","state":"provisioning","labels":{"agent":"codex","branch":"add-csv-export","fleet":"aeo-trackpr","repo":"acme-web","task":"ORI-102"}}
{"id":"sb_o30mu9jhil","name":"aeo-trackpr-3","state":"provisioning","labels":{"agent":"claude-code","branch":"bump-deps","fleet":"aeo-trackpr","repo":"acme-web","task":"ORI-103"}}
```

Each call returned `202`. The object sits under `.sandbox`, not at the top level. All three were `ready` five seconds later. Our run used `aeo-trackpr` as the fleet value and `aeo-trackpr-N` as names, so the outputs on this page show those.

## 2. Add the pr label when the agent opens its PR

`PATCH` replaces the whole label set. We checked: sending only `{"agent":"claude-code"}` left the sandbox with that one label and dropped the other four. So read the current labels, merge, then write. This helper does that:

```bash
add_labels() {   # add_labels <sandbox-id> key=value ...
  id=$1; shift
  cur=$(api $B/sandboxes/$id | jq -c '.sandbox.labels')
  new=$(jq -cn --argjson cur "$cur" \
    '$cur + ($ARGS.positional | map(split("=") | {(.[0]): (.[1:] | join("="))}) | add)' --args "$@")
  api -X PATCH $B/sandboxes/$id -d "{\"labels\":$new}" | jq -c '{id,labels}'
}

add_labels sb_uq23ghng33 pr=412
add_labels sb_gduy6z4b90 pr=415
```

```json
{"id":"sb_uq23ghng33","labels":{"agent":"claude-code","branch":"fix-login-redirect","fleet":"aeo-trackpr","pr":"412","repo":"acme-web","task":"ORI-101"}}
{"id":"sb_gduy6z4b90","labels":{"agent":"codex","branch":"add-csv-export","fleet":"aeo-trackpr","pr":"415","repo":"acme-web","task":"ORI-102"}}
```

The read and the write are two calls. If two processes can label the same sandbox, let only one of them do it, such as the step that opens the PR.

## 3. Find the sandbox for a PR

The `labels` filter matches sandboxes that carry every pair you pass:

```bash
api -G $B/sandboxes --data-urlencode "labels=fleet=acme-agents,pr=412" \
  | jq -r '.sandboxes[] | [.id, .name, .state, .labels.branch] | @tsv'
```

```text
sb_uq23ghng33	aeo-trackpr-1	ready	fix-login-redirect
```

What the other queries we ran returned:

| Query | Result |
| - | - |
| `labels=fleet=…,pr=999` (no such PR) | `200` with `"sandboxes":[]`, so test for an empty list, not an error |
| `labels=fleet=…,agent=codex` | Only `aeo-trackpr-2` |
| `labels=pr` (no value) | `400`, `"code":"invalid_labels"` |

The `labels` filter has no "key is missing" form. To find the agents that haven't opened a PR yet, filter in `jq`:

```bash
sbx list --json | jq -r '.sandboxes[] | select(.labels.fleet == "acme-agents" and (.labels.pr | not)) | .name'
```

```text
aeo-trackpr-3
```

## 4. One table of every agent

`sbx list` returns labels too, so one `jq` line prints the whole fleet:

```bash
sbx list --json | jq -r '["ID","PR","BRANCH","AGENT","STATE"],
  (.sandboxes[] | select(.labels.fleet == "acme-agents")
   | [.id, (.labels.pr // "-"), .labels.branch, .labels.agent, .state]) | @tsv' | column -t
```

```text
ID             PR   BRANCH              AGENT        STATE
sb_o30mu9jhil  -    bump-deps           claude-code  ready
sb_gduy6z4b90  415  add-csv-export      codex        ready
sb_uq23ghng33  412  fix-login-redirect  claude-code  ready
```

`sbx list` shows running sandboxes only. Add `--all` to include stopped ones, or use the API query from step 3, which returns every state.

## 5. See what each agent is doing now

Labels say what an agent was asked to do. Three sources say what it's doing: the last command in `sbx history`, the processes it started in `sbx ps`, and the branch actually checked out in the VM. This loop reads all three for every sandbox in the fleet:

```bash
api -G $B/sandboxes --data-urlencode "labels=fleet=acme-agents" \
  | jq -r '.sandboxes[] | [.id, (.labels.pr // "-"), .labels.agent] | @tsv' \
  | while IFS=$'\t' read -r id pr agent; do
      last=$(sbx history "$id" --limit 5 | jq -r '[.commands[] | select(.command | startswith("git -C ~/acme-web branch") | not)][0] | "\(.status): \(.command[0:40])"')
      branch=$(sbx exec "$id" 'git -C ~/acme-web branch --show-current')
      top=$(sbx ps "$id" | jq -r '[.processes[] | select(.commandId)][0].command[0:30]')
      printf '%s  pr=%s  %s  branch=%s\n  last: %s\n  running: %s\n' "$id" "$pr" "$agent" "$branch" "$last" "$top"
    done
```

```text
sb_o30mu9jhil  pr=-  claude-code  branch=bump-deps
  last: running: cd ~/acme-web && sleep 900
  running: sleep 900
sb_gduy6z4b90  pr=415  codex  branch=add-csv-export
  last: running: cd ~/acme-web && while true; do python3 
  running: /bin/bash -lc cd ~/acme-web &&
sb_uq23ghng33  pr=412  claude-code  branch=fix-login-redirect
  last: exited: curl -s -o /dev/null -w %{http_code} loc
  running: python3 -m http.server 3000
```

Two things we learned running it:

- **Your own checks land in the history.** The first version took `.commands[0]` and every row read `exited: git -C ~/acme-web branch --show-current`, our own branch check from the run before. That's why the loop skips that command. Any `sbx exec` you send to look at a sandbox becomes its newest history entry.
- **`sbx ps` lists system processes too.** Select entries with a `commandId`. Those are the processes started through exec, which is how agents run. The list is sorted busiest first.

Compare the `branch` column with the `branch` label. If they differ, the agent switched branches and its PR may not hold what you expect. To go deeper on one sandbox, follow [see what an AI agent did in its sandbox](/guides/see-what-an-ai-agent-did-in-its-sandbox).

## 6. Write the sandbox into the PR description

The link should work both ways. Labels take you from a PR to a sandbox. A short block in the PR description takes a reviewer from the PR to the sandbox. Sandbox 1 ran `python3 -m http.server 3000` and we hosted it with `sbx host sb_uq23ghng33 3000 --title "PR preview"`. Then this script built the block:

```bash
pr=412
id=$(api -G $B/sandboxes --data-urlencode "labels=fleet=acme-agents,pr=$pr" | jq -r '.sandboxes[0].id')
preview=$(sbx host list "$id" | jq -r '.ports[] | select(.port == 3000) | .url | sub("\\?_token=.*"; "")')
labels=$(sbx info "$id" | jq -r '.sandbox.labels | to_entries | map("\(.key)=\(.value)") | join(", ")')
cat <<MD
<!-- sandboxer -->
**Agent sandbox:** \`$id\` (labels: $labels)
**Preview (private, ask for access):** $preview
**Inspect:** \`sbx history $id\` · \`sbx ps $id\` · \`sbx ssh $id\`
MD
```

```markdown
<!-- sandboxer -->
**Agent sandbox:** `sb_uq23ghng33` (labels: agent=claude-code, branch=fix-login-redirect, fleet=aeo-trackpr, pr=412, repo=acme-web, task=ORI-101)
**Preview (private, ask for access):** https://first-jolly-fox-3000.box.sandboxer.com
**Inspect:** `sbx history sb_uq23ghng33` · `sbx ps sb_uq23ghng33` · `sbx ssh sb_uq23ghng33`
```

We didn't post it to a real PR. Paste it into the description, or send it with your Git host's CLI. The `<!-- sandboxer -->` marker lets a later run find and replace the block.

The script strips the `?_token=…` part on purpose. A private hosted URL works for anyone who has the full link ([hosting](/docs/hosting#private-and-public)). Without the token, the URL returned `401`. With it, we got `302`. Anyone with access to the sandbox can get the full link with `sbx host list <id>`. Post the full link only where everyone who can read the PR should see the preview. For a fuller CI setup, see [a live preview URL for every pull request](/guides/get-a-preview-url-for-every-pull-request).

## 7. Clean up sandboxes for merged PRs

When a PR merges, add `status=merged` to its sandbox. Then delete by label, with a dry run first:

```bash
for id in sb_uq23ghng33 sb_gduy6z4b90; do add_labels $id status=merged; done

# dry run
api -G $B/sandboxes --data-urlencode "labels=fleet=acme-agents,status=merged" \
  | jq -r '.sandboxes[] | [.id, .labels.pr, .labels.branch] | @tsv'

# delete
for id in $(api -G $B/sandboxes --data-urlencode "labels=fleet=acme-agents,status=merged" | jq -r '.sandboxes[].id'); do
  sbx delete "$id" --yes | jq -c '.operation | {targetId, status}'
done
```

```text
sb_gduy6z4b90	415	add-csv-export
sb_uq23ghng33	412	fix-login-redirect
{"targetId":"sb_gduy6z4b90","status":"completed"}
{"targetId":"sb_uq23ghng33","status":"completed"}
```

The agent with no PR yet was left alone. Listing the fleet again showed only `sb_o30mu9jhil  -  bump-deps  ready`. After the delete, `sbx info` on a deleted id exited `4` with `"code":"not_found"`, and the preview URL returned `404`. You can skip the `status` label: get the merged PR numbers from your Git host and run the `pr=` query from step 3 for each one. For sandboxes whose agent died before it labelled anything, see [find and clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes).

## FAQ

### Can I set labels with sbx new or the SDKs?

Not yet. Labels are set through the REST API and the [MCP server](/docs/mcp). `sbx new --name` sets a display name, but names aren't unique and the server can't filter on them. Create through the API, or run `sbx new` and then `PATCH` the labels.

### Why did my PATCH remove the other labels?

A `PATCH` replaces the whole label set; it doesn't merge. Read the current labels, add yours, and send the full set, as `add_labels` does in step 2. `{}` clears them all.

### Do labels stay when a sandbox stops?

Yes. The API query in step 3 returns stopped sandboxes with their labels. `sbx list` hides them unless you pass `--all`.

### Can two sandboxes have the same pr label?

Yes, labels aren't unique. A retry or a [best-of-n run](/guides/best-of-n-agents-with-parallel-forks) can leave several sandboxes on one PR. Handle more than one result, or add a label such as `attempt=2` to tell them apart.

## Related

- [Run several coding agents in parallel, one VM each](/guides/run-coding-agents-in-parallel)
- [See what an AI agent did in its sandbox](/guides/see-what-an-ai-agent-did-in-its-sandbox)
- [Script the sbx CLI with JSON output and jq](/guides/script-sbx-with-json-output-and-jq)
- [Find and clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes)
