# Build a Background Coding Agent That Pushes a Branch for Review

> Run a small service with a task queue and a few workers. Each worker starts a sandbox from a named snapshot of your repo, runs the agent and the tests there, pulls the commit back as a git bundle, pushes the branch with the service's own credentials for review, reports status, and deletes the sandbox.

Source: https://sandboxer.com/guides/build-a-background-coding-agent-that-pushes-a-branch-for-review. Updated 2026-10-27.

**Short answer:** Run a small service with a task queue and a few workers. Each worker starts a sandbox from a named snapshot of your repo, runs the agent and the tests there, pulls the commit back as a git bundle, pushes the branch with the service's own credentials for review, reports status, and deletes the sandbox.

This page is about the service around the agent: the queue, the workers, status, the hand-off of the code, and cleanup. For the steps inside one run (scoped GitHub tokens, issue text as a file, `gh pr create`), see [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox). For prompting several agents by hand, see [Run coding agents in parallel](/guides/run-coding-agents-in-parallel).

What we ran on prod on 2026-10-27: every script on this page, with one task end to end. Two things are stand-ins, and we say so where they appear. The agent is a script, not a model. The "origin" is a bare git repo we created on the machine that runs the service, because we had no GitHub token for a throwaway repo, so the run pushed a branch but opened no pull request.

## At a glance

| Part | What it is here | What you'd use in production |
| - | - | - |
| Front door | `POST /tasks` on `127.0.0.1:8700` | Your chat app's webhook or slash command, calling the same endpoint |
| Queue and workers | Python `queue.Queue` and `WORKERS` threads | A durable queue (a database table, Redis, SQS) |
| Template | Named snapshot with the repo checked out | The same, plus your dependencies ([cached by lockfile hash](/guides/cache-dependencies-in-a-named-snapshot)) |
| Work area | One `small` sandbox per task, `--no-env --no-snapshots --ttl 1800` | The same |
| Agent | `agent-step.sh`, a script | Claude Code, Codex, OpenHands or your own loop |
| Code hand-off | `git bundle` copied out with `sbx scp` | The same |
| Push and PR | The service pushes to a local bare repo; no PR opened | The service pushes to GitHub, then can run `gh pr create` (not tested here) |
| Status | `GET /tasks/<id>` plus a `reply()` hook | The same, with `reply()` posting to the chat thread |
| Our run | 14.3 s from queued to done, sandbox deleted | |

## The one design decision: who holds the push credential

The sandbox runs code you didn't write, from instructions you didn't write. So in this design the sandbox never gets a GitHub token. It commits, writes a [git bundle](https://git-scm.com/docs/git-bundle) of only the new commits, and the service copies that file out with `sbx scp`. The service checks the bundle with `git bundle verify`, fetches the branch from it into its own clone, and pushes with its own credentials.

| | Token in the sandbox (the [issue-to-PR guide](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox)) | Token only in the service (this page) |
| - | - | - |
| What a misbehaving agent can push | Any branch the token allows | Nothing; the service pushes one branch name it chose |
| Can the agent open or edit PRs itself | Yes | No |
| Extra moving parts | None | One bundle file per task |
| Good for | One trusted job, run by hand | A service that takes tasks from many people |

The service also never runs anything the sandbox produced. It only reads git objects from the bundle.

## 1. A throwaway origin and the template snapshot

`setup-remote.sh` made a bare repo, `remote.git`, with a tiny Python project (`textkit.py` with `slugify()` and two unit tests), and a clone of it, `mirror`, that the service works from. With GitHub, `mirror` is a normal clone of your repo.

`build-snapshot.sh` puts the current `main` into a sandbox and saves it as the template. The repo goes in as a bundle too, so the sandbox needs no access to your git host at all:

```bash
#!/usr/bin/env bash
# Build the named snapshot every task starts from: a checkout of main in ~/repo.
# Usage: ./build-snapshot.sh <snapshot-name> <builder-sandbox-name>
set -euo pipefail
snap=$1
git -C mirror fetch -q origin
git -C mirror bundle create -q "$PWD/base.bundle" origin/main
id=$(sbx new --type small --no-env --name "$2" --json | jq -r 'select(.event == "ready") | .id')
trap 'sbx delete "$id" --yes >/dev/null; echo "deleted builder $id"' EXIT
echo "builder $id"
sbx scp base.bundle "$id:/home/user/" >/dev/null
sbx exec "$id" --timeout 120 "git init -q repo && cd repo && git fetch -q ~/base.bundle refs/remotes/origin/main:refs/remotes/origin/main && git checkout -q -B main origin/main && git log --oneline -1 && python3 -m unittest -q 2>&1 | tail -1"
sbx snapshot "$id" "$snap" >/dev/null
sbx snapshot info "$snap" --json | jq -c '{name: .snapshot.name, status: .snapshot.status, type: .snapshot.type}'
```

```bash
time ./build-snapshot.sh bgagent-base bgagent-1
```

```text
builder sb_lapqk7usd6
1053b3b textkit: slugify
OK
{"name":"bgagent-base","status":"ready","type":"small"}
deleted builder sb_lapqk7usd6

real	0m8.360s
```

Our first attempt used `git clone ~/base.bundle` and failed with `warning: You appear to have cloned an empty repository`. A bundle made from `origin/main` carries `refs/remotes/origin/main` and no `HEAD`, so `git clone` finds no branch to check out. `git init` plus an explicit `git fetch` of that ref works.

A named snapshot saves the disk and is independent of the builder, so the `trap` deletes the builder right away ([Snapshots](/docs/snapshots#named-snapshots-templates)). Rebuild the template when your dependencies change. Between rebuilds, each task fetches today's `main` from a fresh bundle, so a stale template only costs a slightly bigger fetch.

## 2. The service

`agentd.py` is the whole service: Python standard library, `sbx` and `git`. It needs `sbx` signed in on the machine that runs it.

```python
#!/usr/bin/env python3
"""agentd: a background coding agent service in one file (Python stdlib + sbx + git).

POST /tasks {"text": "..."}  queue a task (stands in for a chat message)
GET  /tasks/<id>             status, sandbox, branch, PR link, step timings
Each task: sandbox from a named snapshot -> agent step -> tests -> branch
-> the service pushes it -> optional PR (GH_REPO) -> sandbox deleted.
"""
import json, os, queue, subprocess, tempfile, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SNAPSHOT = os.environ.get("SNAPSHOT", "agent-base")  # named snapshot with ~/repo
PREFIX = os.environ.get("PREFIX", "agent")           # sandbox names: PREFIX-<n>
WORKERS = int(os.environ.get("WORKERS", "2"))        # sandboxes at once
MIRROR = os.environ.get("MIRROR", "mirror")          # local clone of origin
GH_REPO = os.environ.get("GH_REPO")                  # owner/repo: open a real PR
AGENT_STEP = os.path.abspath("agent-step.sh")

tasks, jobs, lock, git_lock = {}, queue.Queue(), threading.Lock(), threading.Lock()


def sh(*cmd, timeout=900):
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    if p.returncode != 0:
        tail = "\n".join((p.stdout + p.stderr).strip().splitlines()[-15:])
        raise RuntimeError(f"{cmd[0]} {cmd[1]} exited {p.returncode}:\n{tail}")
    return p.stdout


def update(t, status=None, **fields):
    with lock:
        if status:
            t["status"] = status
            t["steps"].append([round(time.time() - t["t0"], 1), status])
        t.update(fields)


def reply(t):
    """Stand-in for posting back to the chat thread the task came from.
    Fixed after the prod run and checked locally only (see step 5)."""
    if t["status"] == "done":
        msg = f"PR ready: {t['pr'] or t['branch'] + ' @ ' + t['commit']}"
    else:
        msg = f"Failed while {t['steps'][-2][1]}: {t['error'].splitlines()[-1]}"
    print(f"[chat] {t['id']}: {msg}", flush=True)


def run(t):
    sid, branch = None, f"agent/{t['id']}"
    work = tempfile.mkdtemp(prefix=f"{t['id']}-")
    try:
        update(t, "creating")
        out = sh("sbx", "new", "--from", SNAPSHOT, "--type", "small", "--no-env",
                 "--no-snapshots", "--ttl", "1800", "--name", f"{PREFIX}-{t['n']}", "--json")
        sid = next(e["id"] for e in map(json.loads, out.splitlines()) if e.get("event") == "ready")
        update(t, sandbox=sid)

        # Bring the snapshot's checkout up to today's main, then hand over the task.
        with git_lock:
            sh("git", "-C", MIRROR, "fetch", "-q", "origin")
            sh("git", "-C", MIRROR, "bundle", "create", "-q", f"{work}/base.bundle", "origin/main")
        with open(f"{work}/TASK.md", "w") as f:
            f.write(t["text"] + "\n")
        for name in ("base.bundle", "TASK.md"):
            sh("sbx", "scp", f"{work}/{name}", f"{sid}:/home/user/")
        sh("sbx", "scp", AGENT_STEP, f"{sid}:/home/user/")
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "120",
           "git fetch -q ~/base.bundle refs/remotes/origin/main:refs/remotes/origin/main"
           f" && git switch -q -C {branch} origin/main")

        update(t, "agent")
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "600", "bash ~/agent-step.sh")
        update(t, "testing")
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "600", "python3 -m unittest -q")

        # The sandbox commits and exports a bundle; it never holds a push credential.
        sh("sbx", "exec", sid, "--cwd", "repo", "--timeout", "120",
           "git add -A && git -c user.name=agentd -c user.email=agentd@example.com"
           f" commit -q -F ~/TASK.md && git bundle create -q ~/out.bundle origin/main..{branch}")
        sh("sbx", "scp", f"{sid}:/home/user/out.bundle", f"{work}/")
        update(t, "pushing")
        with git_lock:
            sh("git", "-C", MIRROR, "bundle", "verify", "-q", f"{work}/out.bundle")
            sh("git", "-C", MIRROR, "fetch", "-q", f"{work}/out.bundle", f"+{branch}:{branch}")
            sh("git", "-C", MIRROR, "push", "-q", "-f", "origin", branch)
            commit = sh("git", "-C", MIRROR, "rev-parse", "--short", branch).strip()
        pr = None
        if GH_REPO:  # needs GH_TOKEN in the service's environment, not the sandbox's
            pr = sh("gh", "pr", "create", "--repo", GH_REPO, "--head", branch,
                    "--title", t["text"][:72], "--body", f"Task {t['id']}: {t['text']}").strip()
        update(t, "done", branch=branch, commit=commit, pr=pr)
    except Exception as e:
        update(t, "failed", error=str(e))
    finally:
        if sid:
            subprocess.run(["sbx", "delete", sid, "--yes"], capture_output=True)
            gone = subprocess.run(["sbx", "info", sid], capture_output=True).returncode != 0
            with lock:
                t["deleted"] = gone
        reply(t)


def worker():
    while True:
        run(jobs.get())


class API(BaseHTTPRequestHandler):
    def send(self, code, body):
        data = json.dumps(body, indent=1).encode()
        self.send_response(code)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(data)

    def do_POST(self):
        if self.path != "/tasks":
            return self.send(404, {"error": "not found"})
        body = json.loads(self.rfile.read(int(self.headers["content-length"])))
        with lock:
            n = len(tasks) + 1
            t = {"id": f"t{n}", "n": n, "text": body["text"], "status": "queued",
                 "t0": time.time(), "steps": [[0, "queued"]], "sandbox": None,
                 "branch": None, "commit": None, "pr": None, "error": None}
            tasks[t["id"]] = t
        jobs.put(t)
        self.send(202, {"id": t["id"], "status": "queued", "url": f"/tasks/{t['id']}"})

    def do_GET(self):
        with lock:
            if self.path == "/tasks":
                return self.send(200, [{k: t[k] for k in ("id", "status")} for t in tasks.values()])
            t = tasks.get(self.path.removeprefix("/tasks/"))
            body = t and {k: v for k, v in t.items() if k not in ("n", "t0")}
        self.send(200, body) if body else self.send(404, {"error": "no such task"})

    def log_message(self, *args):
        pass


if __name__ == "__main__":
    for _ in range(WORKERS):
        threading.Thread(target=worker, daemon=True).start()
    print(f"agentd: {WORKERS} workers, snapshot {SNAPSHOT}, on 127.0.0.1:8700", flush=True)
    ThreadingHTTPServer(("127.0.0.1", 8700), API).serve_forever()
```

How the parts map to the architecture:

- **Queue.** `POST /tasks` stores the task and puts it on `jobs`. It answers `202` at once; the chat never waits on a sandbox.
- **Workers.** `WORKERS` threads each take one task at a time, so `WORKERS` is the most sandboxes the service runs at once. Keep it at or below your plan's active limit (2 on the trial, 100 on the $20 plan; see [Limits](/docs/limits#concurrency)). Over the limit, `sbx new` fails with `429 limit_reached`; [Handle rate limits and concurrency caps](/guides/handle-rate-limits-and-concurrency-caps) covers retrying.
- **Isolation.** `--no-env` gives the sandbox none of your environment's secrets or credentials. `--no-snapshots` means no snapshot keeps the work, and stopping erases the sandbox. `--ttl 1800` stops it after 30 minutes even if the service dies before its `finally` runs.
- **Status.** Every transition is stamped with seconds since the task was queued. `GET /tasks/<id>` returns the record; `reply()` is where you post to the chat thread.
- **Cleanup.** The `finally` block deletes the sandbox on success and failure alike, then checks `sbx info` fails, and records `deleted`.
- **Git lock.** Workers share `mirror`, so bundle, fetch and push hold `git_lock`.

## 3. The agent step

The service copies `agent-step.sh` into each sandbox and runs it in `~/repo` with the task text in `~/TASK.md`. Ours is a script with two canned edits, so the run is repeatable without a model key:

```bash
#!/usr/bin/env bash
# Scripted stand-in for a coding agent: no model runs. It reads ~/TASK.md and
# edits the checkout in the current directory. Swap the body for your agent.
set -euo pipefail
task=$(cat ~/TASK.md)
case "$task" in
  *word*count*)
    cat >> textkit.py <<'PY'


def word_count(text: str) -> int:
    """Number of whitespace-separated words."""
    return len(text.split())
PY
    cat > test_word_count.py <<'PY'
import unittest

from textkit import word_count


class TestWordCount(unittest.TestCase):
    def test_counts_words(self):
        self.assertEqual(word_count("  two   words "), 2)


if __name__ == "__main__":
    unittest.main()
PY
    ;;
  *underscore*)
    # A plausible but wrong edit: it breaks an existing test.
    sed -i 's/\[^a-z0-9\]+/[^a-z0-9_]+/' textkit.py
    ;;
  *) echo "agent: don't know how to do: $task" >&2; exit 2 ;;
esac
git status --short
```

To use a real agent, replace the body. Pass its model key per task with `-e` on `sbx new`, or use an integrated agent with `sbx prompt` ([Integrated agents](/docs/agents)). We didn't run a model for this page. For steps that can take more than 600 seconds, start them with `sbx exec --detach` and poll `sbx exec --status`, since a synchronous `sbx exec` stops at `--timeout`.

## 4. One task, end to end

Start the service, then send a task the way a chat bot would:

```bash
SNAPSHOT=bgagent-base PREFIX=bgagent-task WORKERS=1 python3 agentd.py &
curl -sS -X POST localhost:8700/tasks -d '{"text": "Add a word_count function to textkit"}'
```

```json
{
 "id": "t1",
 "status": "queued",
 "url": "/tasks/t1"
}
```

While the task was still `creating`, the worker already had a sandbox:

```bash
curl -sS localhost:8700/tasks/t1 | jq -c '{status, sandbox}'
```

```json
{"status":"creating","sandbox":"sb_47qaldghvf"}
```

When the status reached `done` (whitespace in `steps` condensed):

```bash
curl -sS localhost:8700/tasks/t1
```

```json
{
 "id": "t1",
 "text": "Add a word_count function to textkit",
 "status": "done",
 "steps": [[0, "queued"], [0.0, "creating"], [11.0, "agent"], [11.2, "testing"], [14.3, "pushing"], [14.3, "done"]],
 "sandbox": "sb_47qaldghvf",
 "branch": "agent/t1",
 "commit": "ff2ad39",
 "pr": null,
 "error": null,
 "deleted": true
}
```

`pr` is `null` because `GH_REPO` wasn't set: there was no GitHub repo to open it on. The branch was in the throwaway origin, and the sandbox was gone:

```bash
git -C remote.git log --oneline --stat main..agent/t1
sbx info sb_47qaldghvf
```

```text
ff2ad39 Add a word_count function to textkit
 __pycache__/test_textkit.cpython-312.pyc    | Bin 0 -> 1017 bytes
 __pycache__/test_word_count.cpython-312.pyc | Bin 0 -> 758 bytes
 __pycache__/textkit.cpython-312.pyc         | Bin 0 -> 736 bytes
 test_word_count.py                          |  12 ++++++++++++
 textkit.py                                  |   5 +++++
 5 files changed, 17 insertions(+)
{"event":"error","error":"Sandbox not found","code":"not_found","status":404,…}
```

Where the 14.3 seconds went:

| Step | Seconds |
| - | - |
| `sbx new --from` the snapshot, copy in the bundle, task and agent script, fetch `main`, create the branch | 11.0 |
| Agent step (script) | 0.2 |
| Tests, commit, bundle, copy the bundle out | 3.1 |
| Verify, fetch and push in the service | under 0.1 |

The delete ran after `done`, so it isn't in the total. At $0.018/hour for `small`, billed per second, this task's sandbox time cost a few thousandths of a cent. A real agent adds its own minutes and your model provider's charges.

## 5. What the run taught us

**The agent commits whatever the tests leave behind.** Look at the diff above: `git add -A` picked up `__pycache__/` files that `python3 -m unittest` wrote, because the toy repo had no `.gitignore`. A real repo usually has one, but agents generate build output, logs and caches you didn't plan for. Keep a complete `.gitignore`, or have the service reject bundles that add files matching a deny list. Human review of the branch is the last gate; don't make it the only one.

**Tests run before anything leaves the sandbox.** The second canned task, "Make slugify keep underscores", makes a plausible regex edit that breaks an existing test. We ran that step on our own machine against the same repo (not in a sandbox, to stay inside our sandbox budget for this page):

```text
 M textkit.py
…
FAILED (failures=1)
```

In the service, that non-zero exit raises in `sh()`, the task becomes `failed` with the last lines of output in `error`, nothing is pushed, and the sandbox is still deleted. `reply()` turns it into one line for the chat. We checked that formatting locally with a recorded task:

```text
[chat] t1: PR ready: agent/t1 @ ff2ad39
[chat] t2: Failed while testing: FAIL: test_underscores_become_dashes
```

The `reply()` shown in step 2 is the fixed version, checked locally as above, not the one that ran on prod. The one in our prod run crashed on the success path because a dict literal built both messages before picking one. The task had already finished and the sandbox was deleted, since cleanup runs before `reply()`. Keep cleanup ahead of notifications for that reason.

## 6. From this sketch to a real service

| Gap in `agentd.py` | Why it matters | What to do |
| - | - | - |
| Tasks live in memory | A restart loses the queue and in-flight state | Store tasks in a database; re-queue `queued` ones on start |
| Task ids restart at `t1` | After a restart, `agent/t1` would be force-pushed over an old branch | Use unique ids, such as a UUID, in branch names |
| Sandboxes in flight when the service dies | They keep running until `--ttl` stops them | On start, list sandboxes whose name starts with `PREFIX` and delete the ones no live task owns ([Clean up orphaned sandboxes](/guides/clean-up-orphaned-sandboxes)) |
| No retry on `sbx new` | A `429` fails the task | Retry with the wait the error gives ([Retry API calls safely](/guides/retry-sandbox-api-calls-safely-with-idempotency-keys)) |
| `reply()` prints | The chat never hears back | Post to the thread the task came from |
| No PR without `GH_REPO` | Our run stopped at the push | Point `mirror` at GitHub, set `GH_REPO`, and give the service (not the sandbox) a `GH_TOKEN` for that repo |

**Not tested:** we did not run the `GH_REPO` path, so this page shows no pull request being opened. It calls `gh pr create --repo --head --title --body` from the service, as documented in the [gh pr create manual](https://cli.github.com/manual/gh_pr_create), and stores the URL it prints in `pr`.

## FAQ

### Why does the service push instead of the sandbox?

So the code that reads untrusted task text never holds a credential for your git host. The sandbox can only hand back commits in a bundle, and the service decides which branch they land on.

### How many tasks can run at once?

As many as `WORKERS`, up to your plan's active-sandbox limit: 2 on the trial and 100 on the $20 plan. Sandbox starts are also limited per minute, hour and day; check `sbx limits` before a burst. We ran with `WORKERS=1`.

### What happens if the service crashes in the middle of a task?

The sandbox keeps running until its `--ttl` stops it, and with `--no-snapshots` stopping erases it. To clean up sooner, delete sandboxes with your name prefix on startup.

### Do I need to rebuild the snapshot every time main changes?

No. Each task fetches the current `main` from a fresh bundle before it branches. Rebuild the snapshot when dependencies change, so installs stay out of each task.

### Can I use this without GitHub?

Yes. Our run pushed to a bare git repo on the service's machine. Any remote the service can push to works, because the sandbox never talks to it.

## Related

- [Turn a GitHub issue into a pull request from a sandbox](/guides/turn-a-github-issue-into-a-pull-request-from-a-sandbox)
- [Run coding agents in parallel](/guides/run-coding-agents-in-parallel)
- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [Snapshots](/docs/snapshots)
