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-1builder sb_lapqk7usd6
1053b3b textkit: slugify
OK
{"name":"bgagent-base","status":"ready","type":"small"}
deleted builder sb_lapqk7usd6
real 0m8.360sOur 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 /tasksstores the task and puts it onjobs. It answers202at once; the chat never waits on a sandbox. - Workers.
WORKERSthreads each take one task at a time, soWORKERSis 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 newfails with429 limit_reached; Handle rate limits and concurrency caps covers retrying. - Isolation.
--no-envgives the sandbox none of your environment's secrets or credentials.--no-snapshotsmeans no snapshot keeps the work, and stopping erases the sandbox.--ttl 1800stops it after 30 minutes even if the service dies before itsfinallyruns. - 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
finallyblock deletes the sandbox on success and failure alike, then checkssbx infofails, and recordsdeleted. - Git lock. Workers share
mirror, so bundle, fetch and push holdgit_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 --shortTo 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_47qaldghvfff2ad39 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_dashesThe 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.
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.