Skip to content
taktekhqPublic

About

Bounded background agent tasks: one task, one bubblewrap sandbox, one branch, one done-check, one time cap

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

dispatch

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.

Install

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

Configure

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

Commands

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.

What happens to a task

  1. add checks the allowlist and the caps, stores the task in SQLite and starts the worker unit.
  2. 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 into state/work/<id> on branch dispatch/<id>-<slug>.
  3. 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.
  4. 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".
  5. 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 .git points elsewhere.
  6. Status: passed (check exit 0, at least one commit, branch pushed), failed (check non-zero, or no commit), timeout, cancelled or error (with the reason).
  7. 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 for keep_failed.

Logs: state/logs/<id>.log (readable) and state/logs/<id>.jsonl (the raw stream). dispatch show ID prints the tail.

Sandbox details

  • Mounted read-only: /usr, /etc, /opt (and /bin, /lib* symlinks), the Claude Code binary, the dispatch binary, the role's prompt, settings and skills, and read_only paths.
  • 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_PROXY pointing at the proxy, Claude Code's telemetry and auto-update off, and env_file.
  • Namespaces: user, PID, network, IPC, UTS, cgroup. --die-with-parent, --new-session.
  • Claude Code runs with --permission-mode bypassPermissions unless 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.

Tests

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.

Limits

  • One task at a time.
  • Linux only.
  • Credentials in env_file are visible to the agent. Use a key that is scoped and revocable.
  • gh and git run outside the sandbox with your credentials; PRs are opened as you.
  • Verified with Claude Code 2.1.319.

License

MIT. Copyright 2026 Taktek and Nizar Mahmoud.

About

Bounded background agent tasks: one task, one bubblewrap sandbox, one branch, one done-check, one time cap

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages