Run Claude Code on bounded background tasks: one task, one sandbox, one branch, one written done-check, one time cap. The result is a pull request link and a 300-character summary in an inbox.
$ dispatch add --repo acme/app --task "Fix the typo in README.md" --check "go test ./..." --max 15m
queued 1: acme/app, role builder, max 15m0s, check "go test ./..."
worker started (dispatch.service)
$ dispatch inbox
1 passed acme/app https://github.com/acme/app/pull/12
Passed: https://github.com/acme/app/pull/12. Agent: I fixed the typo "recieve" in README.md and committed it.
dispatch is a thin Go program around claude -p. It has no agents talking to each other, no
coordinator model in the control path and no standing workers. Everything that matters is enforced by
code, not by the prompt:
| Rule | How |
|---|---|
| Only repos you list | add refuses any owner/name not in the allowlist file. |
| A fresh copy per task | Each task gets its own clone of origin/<base> on branch dispatch/<id>-<slug>. |
Writes only in the task's clone and /tmp |
The agent runs in bubblewrap. The host is read-only, the home directory is hidden (no ~/.ssh, no ~/.config, no credentials), and /tmp is the task's own directory. |
| Network only to listed hosts | The sandbox has its own empty network namespace. The only way out is an HTTP proxy that dispatch runs outside, on a Unix socket, which refuses any host not on the list. No DNS, no ssh. |
| Caps | Wall clock (--max, default 45 minutes, ceiling 3 hours), output size, --max-turns and --max-budget-usd. A cap kills the whole process group, then anything left in the sandbox's PID namespace, and dispatch checks that nothing survived. |
| Done is the check, not the agent's word | After the agent exits, dispatch runs --check itself, in the same sandbox. Its exit code decides the status. The agent's report is stored, never trusted. |
| Main is never pushed | The sandbox holds no credentials. dispatch pushes the task's commit only to dispatch/<id>-<slug>, and refuses a pass if origin/<base> moved to include a task commit. |
| Nothing starts on boot | The worker's systemd unit has no [Install] section, so it cannot be enabled. dispatch add starts it; it exits when the queue stays empty. |
| A kill switch | dispatch stop-all kills running tasks and the worker, stops and masks the unit, and blocks new work until dispatch resume. |
Linux only (bubblewrap). Needs Go 1.26+, bwrap, git, Claude Code, and
gh if you want pull requests opened.
go install github.com/taktekhq/dispatch@latest
dispatch check-sandbox # proves the sandbox works on this machine
dispatch install # optional: the systemd user unit for the worker
On Ubuntu 24.04 and later, unprivileged user namespaces are restricted by AppArmor, and bwrap fails with "setting up uid map: Permission denied". Allow bwrap with an AppArmor profile:
sudo tee /etc/apparmor.d/bwrap <<'EOF'
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
}
EOF
sudo apparmor_parser -r /etc/apparmor.d/bwrap
Two files in ~/.config/dispatch/ (or $XDG_CONFIG_HOME/dispatch/). Both are optional except the
allowlist, which starts empty, so nothing runs until you list a repo.
repos, one repo per line:
# repos dispatch may work on
acme/app
acme/docs
config.json, every key optional:
{
"git_url": "git@github.com:%s.git",
"env_file": "~/.config/dispatch/agent.env",
"allow_hosts": ["api.anthropic.com", "github.com", "proxy.golang.org", "sum.golang.org"],
"read_only": ["/opt/toolchains"],
"default_max": "45m",
"max_ceiling": "3h",
"check_timeout": "15m",
"max_turns": 150,
"max_budget_usd": 5,
"model": "",
"open_pr": true,
"role": "role",
"keep_failed": "24h"
}| Key | Default | Meaning |
|---|---|---|
state_dir |
~/.local/state/dispatch |
Database, logs, clones, git cache |
repos_file |
repos next to the config |
The allowlist |
git_url |
https://github.com/%s.git |
Clone and push URL; %s is owner/name. dispatch fetches and pushes outside the sandbox with your normal git credentials. |
claude |
claude |
The Claude Code binary |
role |
role |
The role launcher, which gives each task its role's system prompt, skills and settings. "none" runs plain Claude Code. |
role_brain |
role's default | Passed to role as --brain |
env_file |
none | KEY=VALUE lines for the agent. Use it for credentials the agent needs to call the model, such as ANTHROPIC_API_KEY, or ANTHROPIC_BASE_URL plus ANTHROPIC_AUTH_TOKEN for a quota proxy. The host of ANTHROPIC_BASE_URL joins the network allowlist. These values are visible inside the sandbox, so give the agent a key you can revoke. |
allow_hosts |
Anthropic API, GitHub, Go, npm and PyPI hosts | Network allowlist: host (ports 443 and 80), host:port, *.domain |
read_only |
none | Extra host paths shown read-only in the sandbox (/usr, /etc and /opt always are) |
path |
/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin and sbin |
PATH inside the sandbox |
git_name, git_email |
your git config | Author of the agent's commits |
default_max, max_ceiling |
45m, 3h |
Wall-clock cap when add has no --max, and the largest --max allowed |
check_timeout |
15m |
Cap on the done-check |
max_output_bytes |
50 MiB | Output cap |
max_turns |
150 | --max-turns for Claude Code; negative turns it off |
max_budget_usd |
off | --max-budget-usd for Claude Code |
open_pr |
true |
Open a PR with gh pr create after a pass |
keep_failed |
24h |
How long a failed task's clone is kept for inspection |
idle_exit |
60s |
How long the worker waits for new tasks before it exits |
dispatch add --repo OWNER/NAME --task "..." --check "CMD" [--role builder] [--max 45m] [--base main] [--model M] [--no-start]
dispatch ls [--all] queue and recent results
dispatch show ID task, result, PR URL, log tail
dispatch inbox finished tasks not yet read (marks them read)
dispatch run [--once] the worker: takes queued tasks in order, one at a time
dispatch cancel ID
dispatch stop-all kill running tasks, stop and mask the worker unit
dispatch resume undo stop-all (does not start anything)
dispatch install write the worker's systemd user unit (never enabled)
dispatch check-sandbox prove the sandbox blocks writes and network here
--check is a shell command run with sh -c in the task's clone. Make it the test you would trust:
go test ./..., npm test, make check && ./scripts/live-check. --task-file reads a long task
from a file.
For a task that deploys, use livecheck as the check:
--check "go test ./... && livecheck" passes only when production shows what the repo's
livecheck.yaml says, so the task passes when the change is live, not when the code is written. The
sandbox must see the binary and reach the sites: install it in /usr/local/bin (/usr is mounted, the
home directory is not) and list the sites' hosts in allow_hosts. Exit 3 (a fresh merge still
deploying) counts as a failure here. When the sandbox cannot fetch, {{git.main}} falls back to the
clone's origin/main from when the task started.
addchecks the allowlist and the caps, stores the task in SQLite and starts the worker unit.- The worker (one at a time; a lock file keeps a second one out) fetches
<base>into a bare cache repo that only dispatch writes, and clones it intostate/work/<id>on branchdispatch/<id>-<slug>. - It starts the proxy on a Unix socket, then runs Claude Code in bubblewrap: the role's system prompt
(from
role run <role> --dry-run), the task prompt on stdin,-p --output-format stream-json, the caps. The prompt tells the agent to commit as it goes, not to push, and to run the check. - On a cap, cancel or stop-all: the process group gets SIGTERM, then SIGKILL; then every process still in the sandbox's PID namespace gets SIGKILL; the log records "no process left".
- dispatch commits whatever the agent left uncommitted (in the sandbox), runs the check (in the
sandbox), and fetches the agent's HEAD into its cache. The fetch runs none of the clone's hooks, and
refuses a clone whose
.gitpoints elsewhere. - Status:
passed(check exit 0, at least one commit, branch pushed),failed(check non-zero, or no commit),timeout,cancelledorerror(with the reason). - On a pass dispatch pushes the commit to
dispatch/<id>-<slug>and opens a PR whose body says what dispatch ran, what it did not run, and the agent's own report, marked unverified. The clone is deleted; a failed one is kept forkeep_failed.
Logs: state/logs/<id>.log (readable) and state/logs/<id>.jsonl (the raw stream). dispatch show ID
prints the tail.
- Mounted read-only:
/usr,/etc,/opt(and/bin,/lib*symlinks), the Claude Code binary, the dispatch binary, the role's prompt, settings and skills, andread_onlypaths. - Hidden (empty and read-only):
/home,/root,/run,/var,/mnt,/media,/srv. - Writable: the task's clone and
/tmp(the task's own directory, also at/var/tmp). - Environment: cleared, then
HOME=/tmp/home,PATH, git author,HTTPS_PROXYpointing at the proxy, Claude Code's telemetry and auto-update off, andenv_file. - Namespaces: user, PID, network, IPC, UTS, cgroup.
--die-with-parent,--new-session. - Claude Code runs with
--permission-mode bypassPermissionsunless the role sets a mode: in print mode nobody could answer a permission prompt, and the sandbox is the boundary.
Claude Code also has a built-in sandbox, but it covers only the Bash tool; file edits and web fetches run in its own process under permission rules. Wrapping the whole process in bubblewrap puts every tool behind the same walls.
go test ./...
DISPATCH_REQUIRE_SANDBOX=1 go test ./... # fail instead of skip when bwrap cannot run
The sandbox tests run bubblewrap for real: writes outside the clone fail and do not appear on the host,
the real home is invisible, unlisted hosts get 403 from the proxy, direct connections fail, a timeout
kills even a process that left the group with setsid, the check decides the status, and stop-all
leaves nothing running.
- One task at a time.
- Linux only.
- Credentials in
env_fileare visible to the agent. Use a key that is scoped and revocable. ghand git run outside the sandbox with your credentials; PRs are opened as you.- Verified with Claude Code 2.1.319.
MIT. Copyright 2026 Taktek and Nizar Mahmoud.