Track Which Agent Sandbox Is Working on Which PR
Updated November 1, 2026 .md
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). This page is the bookkeeping layer. Starting the agents is covered in run several coding agents in parallel, and reading one agent's full trail in 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.
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}'{"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:
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{"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:
api -G $B/sandboxes --data-urlencode "labels=fleet=acme-agents,pr=412" \
| jq -r '.sandboxes[] | [.id, .name, .state, .labels.branch] | @tsv'sb_uq23ghng33 aeo-trackpr-1 ready fix-login-redirectWhat 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:
sbx list --json | jq -r '.sandboxes[] | select(.labels.fleet == "acme-agents" and (.labels.pr | not)) | .name'aeo-trackpr-34. One table of every agent#
sbx list returns labels too, so one jq line prints the whole fleet:
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 -tID 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 readysbx 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:
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"
donesb_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 3000Two things we learned running it:
- Your own checks land in the history. The first version took
.commands[0]and every row readexited: git -C ~/acme-web branch --show-current, our own branch check from the run before. That's why the loop skips that command. Anysbx execyou send to look at a sandbox becomes its newest history entry. sbx pslists system processes too. Select entries with acommandId. 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.
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:
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<!-- 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). 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.
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:
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}'
donesb_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.
FAQ#
Can I set labels with sbx new or the SDKs?#
Not yet. Labels are set through the REST API and the MCP server. 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 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#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.