Throwaway runtime buckets for AI agents.
Resolve, fetch, and run any CLI tool in an isolated ephemeral environment — without installing it globally.
Borrowing concepts from pkgx (bottle format, dist server, env composition, companion deps) and exosphere's exo-hydra provisioning pipeline (resolve → install → compose → exec).
AI agents need ad-hoc runtimes: "run this with Node 20", "test this with Python 3.11", "build with Go 1.22 + CMake". Installing these globally pollutes the host and creates version conflicts.
buckets gives each command its own throwaway runtime — fetched on demand
from dist.pkgx.dev, cached locally, cleaned up when done.
Any agent with shell access (bro-cli's bash tool, a dispatched Claude Code
horse, any runner) can call buckets directly once it's on PATH — it's a
plain CLI, no MCP/tool registration needed. For concurrent fleet agents,
prefer bucket-bridge (jokersquad/bin/bucket-bridge, symlinked to
~/.local/bin/) over calling buckets directly: it's a transparent wrapper
that points every agent at one shared BUCKETS_CACHE_DIR
(~/.cache/squadron-buckets) instead of each agent maintaining its own
cache, so N agents resolving the same spec (node@20) don't each
redundantly download the same bottle. Safe under concurrency — see
install.rs's atomic-rename install.
# Run one-off commands
buckets run node@20 -- script.js --flag arg
buckets run python@3.11 -- python3 -c "print('hello from a bucket')"
buckets run rust@latest -- rustc --version
# Multi-package environments
buckets run node@20 python@3.11 -- node -e "console.log('and python is at', process.env.PATH)"
buckets run go@1.22 cmake@latest -- go version
# Language registries (BUCKETS-15) — install into the shared cellar
buckets run pypi:six@1.16 -- python3 -c "import six; print(six.__version__)"
buckets run npm:is-number@7 -- node -e "console.log(require('is-number')(3))"
buckets env pypi:requests@2.31 npm:is-number@7 # PYTHONPATH + NODE_PATH
# Open an interactive shell with the runtime in PATH
buckets shell node@20
buckets shell python@3.11 --shell /bin/zsh
# Print the composed environment as shell exports (composable)
eval "$(buckets env node@20)"
buckets env node@20 python@3.11 --json
# See what a spec resolves to (no installation)
buckets info node@20
buckets info git@latest
# List cached installations
buckets list
# Reclaim disk space — the cache only grows by default, this is how you
# prune it back. A bare `buckets clean` with no target is refused (the
# cache dir is often shared across concurrent agents); pass a project,
# --all, or both --all and --older-than.
buckets clean node@18 # remove one version
buckets clean node # remove every version of a project
buckets clean --all --dry-run # preview a full wipe first
buckets clean --all --older-than 30d # remove entries not touched in 30 days
# Persistent sessions — an OverlayFS mount shared across multiple `exec`
# calls, so files written by one command survive into the next (unlike
# `run`, which is a fresh sandbox every time).
buckets session start node@20
buckets session exec <session-id> -- node another-script.js
buckets session list
buckets session stop <session-id> --purge
# Isolated virtual networks — buckets in the same net share a private
# loopback (127.0.0.1) with no host network access, and a port can be
# forwarded in from the host.
buckets net create dev-net
buckets net run dev-net node@20 -- node server.js
buckets net expose dev-net 8080:3000
buckets net rm dev-net
# Herds — a fleet of bucket replicas with restart/scale/health-reconcile,
# each replica getting HERD_NAME/HERD_REPLICA_INDEX env vars to self-identify.
# `deploy` owns the processes and serves a Unix control socket so `scale` /
# `status` / `stop` from another CLI process can talk to it live.
buckets herd deploy worker --spec node@20 --replicas 5 --net herd-net -- node worker.js
buckets herd status worker
buckets herd scale worker --replicas 8
buckets herd stop worker
# Build/test/run a real project — clone (git URL) or use (local path),
# detect the build system, resolve the toolchain it needs, build inside
# a sandboxed bucket. Doesn't touch the host filesystem outside the
# project dir + the resolved toolchain's own cache.
buckets build /path/to/repo
buckets build https://github.com/owner/repo --test
buckets build . --test --run
# Ephemeral worktrees — a task gets its own working copy at a fresh
# branch (git worktree add, not a full clone — cheap, shares the repo's
# object store). Build/test it like any other local path. "Destroyed
# once you merge": removing an unmerged worktree's branch is refused by
# git itself (git branch -d's own safety check) unless --force.
buckets worktree create /path/to/repo my-task-branch
buckets build "$(buckets worktree create /path/to/repo my-task-branch)" --test
buckets worktree remove /path/to/repo /path/to/repo-my-task-branch my-task-branch
buckets worktree list /path/to/repo
# GUI buckets — run a GUI app against a fresh, isolated Xvfb X server
# (not the host's real display). Only the throwaway server's own socket
# + a session-scoped Xauthority cookie are visible inside the sandbox.
buckets gui --screenshot /tmp/out.png --timeout 5 -- glxgears
buckets gui node@20 --width 1280 --height 800 -- node gui-script.js
# Site buckets — open a URL with a real, OS-enforced per-origin storage
# sandbox (revives the intent behind exosphere-apps' site-capsulizer,
# which was found unenforced scaffolding — see the README's "Site
# buckets" section below). Defaults to a headless browser binary
# (surfer) on PATH; --gui runs a windowed one (super-surfer) in a
# fresh Xvfb.
buckets site https://example.com
buckets site https://example.com --gui --screenshot /tmp/site.png --timeout 5
buckets site https://example.com --incognito # ephemeral storage, removed on exitbuckets build <dir> --sched routes a cargo build through
buildsched's admission-controlled pipeline (bob): per-crate
jobs gated on memory watermarks, PSI pressure, and disk headroom instead of one
monolithic cargo build. Opt-in at compile time — the buildsched cargo
feature (OFF by default, cargo build --features buildsched) adds a path dep
on the sibling buildsched repo; the default build stays fully self-contained.
Cargo projects with a single [package] only: virtual workspaces and non-cargo
projects print a one-line notice and fall back to the normal build path. Sched
builds always run unsandboxed (the pipeline spawns its own cargo subprocess
tree, which can't live inside the per-command bwrap wrap); the bucket toolchain
env is still injected into every pipeline subprocess.
run/shell/build (and gui, site, session, Bucketfile RUN) all
execute under bubblewrap
(bwrap). Only the resolved toolchain's own install dirs (read-only), the
base OS dirs (/usr, /bin, /lib*, read-only) and the invocation/project
directory (read-write) are visible inside, plus a private /tmp tmpfs.
Network is off by default for run/shell and on for build/Bucketfile
RUN/site (package registries and browsers need it).
If bwrap is not installed, buckets refuses to run and says how to
fix it. Nothing silently downgrades. The two weaker modes are explicit:
| flag | backend | what you lose |
|---|---|---|
| (default) | bwrap | — |
--allow-weaker-sandbox |
proot, only if bwrap is missing | network, PID and IPC namespaces are the host's; no seccomp. A warning is printed. cgroup limits and --timeout still apply |
--no-sandbox |
plain subprocess | all isolation. The environment is still clean (unless --inherit-env), cgroup limits and --timeout still apply, seccomp does not |
A bwrap too old for a required flag (--clearenv, --die-with-parent,
--new-session, --unshare-ipc, --unshare-uts, --seccomp) is also a
refusal, not a downgrade. Optional flags (--cap-drop, --disable-userns,
--unshare-cgroup-try, --hostname) are used when the installed bwrap has
them (probed once from bwrap --help).
- Namespaces: mount, PID, IPC, UTS (hostname
bucket), cgroup, network (unless allowed /--net), and a user namespace with nested user namespaces disabled (--disable-userns). - Process hygiene:
--die-with-parent(killingbucketskills the sandbox),--new-session(noTIOCSTIinjection into your terminal;shellkeeps your session for job control),--cap-drop ALL(the sandboxed process has empty capability sets). - Environment:
--clearenv, then only the composed toolchain env (PATH,LD_LIBRARY_PATH,PYTHONPATH,NODE_PATH, ...),HOME=/tmp, andTERM,COLORTERM,NO_COLOR,LANG,LANGUAGE,LC_*,TZfrom the host. Tokens and other host variables do not reach the sandbox (the bwrap process itself also starts with an empty environment).--inherit-envrestores the old pass-everything behaviour. - seccomp (
--seccomp off|default|strict, defaultdefault): a BPF program built with zandbox and handed to bwrap on an inherited memfd (--seccomp FD). bwrap installs it just before exec'ing your command.default: zandbox'sApplicationprofile (files, processes, threads, signals, timers, sockets) plus a compatibility list, minusunshare. Anything else fails withEPERM:ptrace,mount,setns,bpf,perf_event_open,keyctl, module loading,io_uring_*(libuv and cargo fall back), ... Measured withstrace -fto cover python3 (threads, subprocess, multiprocessing, venv, pip), node (child_process, worker_threads), bash, uv, npm, git andcargo build.strict: zandbox'sDefaultprofile (no sockets); violations kill the process withSIGSYS. Anything that opens a socket dies, including NSS lookups (ls -l,id).guiandsiterun with seccomp off (GUI toolkits, and browsers that bring their own sandbox).
buckets run --memory 512M --pids 256 --cpu 50% --timeout 30s python@3.12 -- python job.py-
--timeoutkills the whole sandbox when it expires (exit status 124). Works without a cgroup. -
--memory(memory.max, withmemory.swap.max=0),--pids(pids.max; bwrap itself uses 2) and--cpu(cpu.max, percent of one CPU) are cgroup v2 limits on the whole sandbox, bwrap included. zandbox creates a per-run cgroup and thebwrapprocess joins it inpre_exec, before exec, so there is no attach race. They need a delegated cgroup v2 directory:--cgroup-parent DIRor$BUCKETS_CGROUP_PARENT. Without one, the run is refused. With systemd:systemd-run --user --scope -p Delegate=yes bash -c ' CG=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/self/cgroup) mkdir $CG/leaf $CG/sandboxes echo 0 > $CG/leaf/cgroup.procs # move this shell out of the scope root echo "+pids +memory +cpu" > $CG/cgroup.subtree_control export BUCKETS_CGROUP_PARENT=$CG/sandboxes buckets run --memory 512M --pids 64 python@3.12 -- python job.py'
The sandbox flags (--allow-weaker-sandbox, --inherit-env, --seccomp,
--memory, --pids, --cpu, --timeout, --cgroup-parent) exist on
run, net run and shell.
sandbox::try_sandboxed_command(program, args, cwd, env, &profile) returns
Result<Sandbox, SandboxError>. Sandbox::status() / spawn() +
SandboxChild::wait() honour the timeout; into_command() gives the bare
std::process::Command. SandboxProfile gained binds, fallback,
env_policy, seccomp, limits, keep_terminal_session, share_ipc and
allow_nested_userns, all defaulting to the strict choice, so
SandboxProfile { .., ..Default::default() } literals keep compiling. The
old infallible sandboxed_command still exists: when the sandbox can't be
built it returns a command that runs nothing and exits 126 with the reason
on stderr.
buckets gui runs a GUI command against a brand-new Xvfb
X server, not the host's real :0. Borrows the concept from
x11docker — a session-scoped
MIT-MAGIC-COOKIE Xauthority cookie, generated fresh per session and bound
into the sandbox alongside exactly ONE file (the specific /tmp/.X11-unix/X<N>
socket, never the whole directory) plus DISPLAY/XAUTHORITY env vars.
Deliberately a nested server, not a --hostdisplay-style reuse of the real
session — X11 has no native per-client window isolation, so a fresh Xvfb
instance means nothing on the real display is ever exposed. Verified live:
a client with the wrong/missing XAUTHORITY is refused by the X server
("Authorization required, but no authorization protocol specified");
the right cookie succeeds. Session cleanup (Xvfb process, socket, cookie
file) happens on drop regardless of how the command exited.
vs. squadron/bin/display-up: a lighter-weight sibling tool exists in
squadron for a different shape of problem — attaching automation
(xdotool/import, or interactd's computer_use/vision_start/
atspi_start) to an app you're launching yourself rather than sandboxing
a command for you. display-up allocates a per-agent Xvfb display
number (flock-serialized, idempotent, keyed by agent identity) but does
NOT provide buckets gui's Xauthority-gated/bwrap-sandboxed containment
— any process that guesses the right :N can still connect. Use
buckets gui/buckets site --gui when you want the command sandboxed
too (real OS-level containment); use display-up when you just need a
private display surface to avoid colliding with another agent's session
on the shared host :0 (the incident that motivated it: blind xdotool
automation during surfer-browser's SHELL-SPLIT-3 spammed input into
another agent's window). The two don't conflict — buckets gui's
XvfbSession could reuse display-up's allocation logic later if the
two ever want to converge, but that's not needed today.
buckets site <url> runs a browser against a URL with a real, OS-enforced
per-origin storage sandbox — reviving the intent behind exosphere-apps'
exo-site-capsulizer (storage-capsule/net-capsule/worker-capsule), which
was found to be ~95% unenforced scaffolding (check_request() never
called, storage VFS dead code, workers never started) and replaced in
surfer-browser with a shim explicitly documented as "not an enforcement
layer" — real privacy there is a JS fetch/XHR interceptor, still
in-process, still not real OS isolation.
buckets site gets the enforcement for free from the sandboxing this
project already has: each host gets its own read-write bind
($BUCKETS_CACHE_DIR/sites/<host>/, persistent — real browsing-profile
semantics — or --incognito for a tempdir removed on exit) and nothing
else on the host is visible inside the sandbox. Headless by default
(surfer on PATH); --gui runs a windowed browser (super-surfer) inside
a fresh Xvfb, same mechanism as buckets gui. Neither browser binary is
built by buckets itself — build it once in surfer-browser (cargo build --release -p surfer --features cli for headless, -p super-surfer --features bliss for GUI), then point buckets site at it (PATH lookup
by default, or --browser-bin <path>).
Not built: per-domain/third-party network filtering (still just the
existing coarse network on/off toggle) and building the browser binary
itself (a separate, one-time cargo build, not part of this command).
<tool>[@<version>]
| Spec | Meaning |
|---|---|
node@20 |
Node.js 20.x (latest in ^20 range) |
python@=3.11.0 |
Exact Python 3.11.0 |
rust@latest |
Latest stable Rust |
node |
Latest (same as @latest) |
go@^1.22 |
Standard caret semver |
go@>=1.22 |
Greater-or-equal |
rust@~1.70 |
Tilde (patch-level) semver |
| Alias | Resolves to |
|---|---|
node |
nodejs.org |
python |
python.org |
rust |
rust-lang.org |
go |
golang.org |
git |
git-scm.com |
cmake |
cmake.org |
ripgrep / rg |
BurntSushi/ripgrep |
curl |
curl.se |
gh |
github.com/cli |
docker |
docker.com |
kubectl |
kubernetes.io/kubectl |
terraform |
terraform.io |
aws |
amazon.com/aws-cli |
neovim |
neovim.io |
tmux |
tmux.github.io |
| ... and 45+ more | See src/index.rs |
- Parse spec → project + semver constraint
- Resolve alias → full pkgx project name
- Collect companions ��� auto-include deps (e.g. openssl for curl, cmake)
- Resolve versions → check cache first, then remote
versions.txt - Install → download
.tar.xzbottle, XZ-decompress, extract, atomic rename - Symlink → create
v*,v<major>,v<major.minor>→ latest version - Compose env → PATH, LD_LIBRARY_PATH, CPATH from installation dirs
- Run or export the environment
| Command | Description |
|---|---|
run <specs> -- <cmd> |
Resolve, install, and exec a command |
shell <specs> |
Open an interactive shell with the runtime |
env <specs> |
Print shell exports (--json for structured output) |
info <specs> |
Show resolution without installing |
list |
Show cached installations |
clean <project>[@version] |
Remove one cached project or version |
clean --all [--older-than <30d|12h|2w>] [--dry-run] |
Bulk-remove cached installations to reclaim disk space |
build <path-or-url> [--test] [--run] |
Detect + build (+ test/run) a real project, sandboxed |
worktree create <repo> <branch> [--from <base>] |
Create an ephemeral worktree (prints its path) |
worktree remove <repo> <path> <branch> [--force] |
Remove a worktree + its branch (git refuses if unmerged, unless --force) |
worktree list <repo> |
List existing worktrees |
gui [specs] -- <cmd> [--screenshot <path>] [--timeout <secs>] [--width <N>] [--height <N>] |
Run a GUI command in a sandboxed bucket against a fresh Xvfb X server |
site <url> [--browser-bin <path>] [--gui] [--incognito] [--screenshot <path>] [--timeout <secs>] |
Open a URL with a real per-origin storage sandbox |
session start <specs> [--tmpfs|--zram] [--size <N>] [-- <cmd>] |
Start a persistent OverlayFS session (files survive across exec calls) |
session exec <session-id> -- <cmd> |
Run a command inside an existing session |
session stop <session-id> [--purge] |
Stop a session (--purge also removes its overlay state) |
session list [--json] |
List active sessions |
net create <name> / net rm <name> / net ls |
Create/remove/list named virtual networks |
net run <name> <specs> -- <cmd> |
Run a bucket joined to a named network |
net expose <name> <host_port>:<bucket_port> |
Forward a host port into a virtual network |
herd deploy <name> --spec <spec> [-n <replicas>] [--net <name>] [--restart <policy>] -- <cmd> |
Deploy a fleet of bucket replicas |
herd ls / herd status <name> |
List herds / show per-replica status |
herd scale <name> -n <replicas> |
Live hot-scale via the deploy process's Unix control socket |
herd stop <name> |
Stop via control socket (clean deploy shutdown); falls back to SIGTERM from state.json |
| Env var | Default | Description |
|---|---|---|
BUCKETS_DIST_URL |
https://dist.pkgx.dev |
Distribution server URL |
BUCKETS_CACHE_DIR |
~/.cache/buckets/ or ~/.buckets/ |
Local cache directory |
BUCKETS_WORKTREE_DIR |
sibling of the source repo | Parent directory for worktree create (see below) |
BUCKETS_WORKTREE_DIR defaults to creating each worktree as a sibling of
its source repo (/path/to/repo-my-branch next to /path/to/repo), not
a fixed cache location — required for relative sibling path-dependencies
(../other-repo) to keep resolving correctly from inside the worktree.
sudo flare-up --agent myagent --quiet # once per session (needs root)
buckets --flare run node@20 -- node script.js--flare points BUCKETS_CACHE_DIR at an already-provisioned zram-backed
ext4 mount (squadron/bin/flare-up) instead of host disk — same
resolve/install/sandbox pipeline, just backed by compressed RAM that
evaporates on reboot instead of accumulating on an SSD. Deliberately does
not provision a session itself: flare-up needs root and is meant to
be session-scoped (run once per agent/session, reused across many
buckets invocations), not re-provisioned per command. --flare errors
clearly if no session is live rather than silently falling back to host
disk — run sudo flare-up first. See
/workspace/projects/FLARE_FIREFLY_DESIGN.md for the full design and why
this doesn't repeat the earlier zram-for-inference performance problems.
- Bottle format:
.tar.xzfromdist.pkgx.dev/<platform>/<arch>/<project>/v<version>.tar.xz - Cache layout:
~/.buckets/<project>/v<version>/bin/... - Symlink version scheme:
v*→ latest,v20→ latest v20.x.x - Companion packages: auto-included deps (openssl for curl, etc.)
- Env composition: scan
bin/,lib/,include/,share/,man/per installation - Shell exports format:
export PATH="/path/to/bucket/bin:$PATH" - JSON output:
--jsonfor structured consumption - Multi-package:
buckets run node@20 python@3.11 -- node -e "..."
cargo install --path .
# or, from crates.io (published as `crush-buckets`, binary is still `buckets`)
cargo install crush-bucketsThis repo doubles as its own Claude Code plugin marketplace — install it directly from GitHub, no separate marketplace repo needed:
/plugin marketplace add nixpt/buckets
/plugin install buckets@buckets
The plugin ships a buckets-usage skill that teaches a model when to reach
for a throwaway bucket instead of a global install, plus the core command
reference. It does not vendor a compiled buckets binary (a public
OSS repo shouldn't ship arch-specific blobs) — install buckets itself via
one of the two commands above first.
buckets is a clean-room reimplementation, not a fork — no source from the projects below is copied here, only the formats/behaviors listed under "Features borrowed from pkgx" above, plus the general resolve→install→compose→exec pipeline shape.
- pkgx (Max Howell, Jacob Heider;
Copyright 2022–23 pkgx inc.; Apache-2.0) — the bottle format,
dist.pkgx.devdistribution protocol, cache/symlink layout, companion-package resolution, and env-composition approach are all pkgx's design, reimplemented here. - exosphere's
exo-hydraand exo-light'sexo-hydra— the same pkgx-derived provisioning pipeline shape (resolve → install → compose → exec), adapted for this project's standalone/sync use case. See/workspace/projects/CLAUDE.md's provisioning-lineage note for how the three relate.
Real-world adoption note (2026-07-11): of the three pkgx-derived
implementations above, buckets is currently the only one with real
downstream consumers — interactd's BucketsTool (shells out to the
buckets CLI) and flame's flame-core::kindle (a real compile-time
Rust dependency, buckets = { path = "../../../buckets", optional = true },
one of flame's three "fuel" backends alongside local paths and language
registries — see flame's own README: "Buckets remains the fleet
tool-bottle CLI; Flame is the workspace OS"). Both exo-hydra copies
remain fully built but honestly self-documented as unwired (their own
READMEs say so) — resolve/install/manifest pipelines with no consumer
calling into them yet.
Dual-licensed under MIT OR Apache-2.0, your choice — see LICENSE-MIT and
LICENSE-APACHE (the standard convention across the Rust ecosystem).
(Acknowledgments above are for design/format credit, not a licensing
obligation — no pkgx source is redistributed.)