Skip to content
Guides · Agent cookbook

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

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

4. 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 -t
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:

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

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}'
done
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.

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.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

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.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

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.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

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.

Read the guide
Best E2B alternatives in 2026, ranked

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.

Read the guide