Skip to content
Guides · Agent cookbook

Build a Background Coding Agent That Pushes a Branch for Review

Updated October 27, 2026 .md

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. For prompting several agents by hand, see 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)
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 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) 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:

#!/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}'
time ./build-snapshot.sh bgagent-base bgagent-1
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). 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.

#!/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). Over the limit, sbx new fails with 429 limit_reached; 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:

#!/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). 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:

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"}'
{
 "id": "t1",
 "status": "queued",
 "url": "/tasks/t1"
}

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

curl -sS localhost:8700/tasks/t1 | jq -c '{status, sandbox}'
{"status":"creating","sandbox":"sb_47qaldghvf"}

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

curl -sS localhost:8700/tasks/t1
{
 "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:

git -C remote.git log --oneline --stat main..agent/t1
sbx info sb_47qaldghvf
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):

 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:

[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)
No retry on sbx new A 429 fails the task Retry with the wait the error gives (Retry API calls safely)
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, 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.

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