How to build for a destination with ordinary flags. bundleup has no named targets (ADR-0039): each recipe says which Python, platform, format and size limit the destination needs, so you can see and change every choice. CI builds gauntlet 14 (python-pptx, lxml, Pillow) with each recipe marked CI, from a Mac and from Linux.
A skill's scripts fail with ModuleNotFoundError on a machine that doesn't have their packages,
and installing them on first use needs pip, a package index and the network. Instead, ship the
packages in one file next to the scripts and run every script through it
(ADR-0040):
my-skill/
SKILL.md
pyproject.toml # [project] requires-python = ">=3.10", dependencies = ["python-pptx", "pymupdf", ...], then `uv lock`
uv.lock
scripts/
deck_edit.py # plain scripts the agent can read
check_setup.py
deps.pyz # built below
bundleup build my-skill --entry python -o my-skill/scripts/deps.pyz \
--split 100MB \
--python-platform all(or keep the settings in the project, so bundleup build my-skill -o my-skill/scripts/deps.pyz is
enough:
[tool.bundleup]
entry = "python"
split = "100MB"
python-platform = "all" # or ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "x86_64-manylinux_2_28", "aarch64-manylinux_2_28"]) and in SKILL.md: python3 scripts/deps.pyz scripts/deck_edit.py ... (python on Windows). The
scripts' own helper modules import as usual (the script's folder comes first on sys.path), and
a script that starts another one from that folder with sys.executable shares the bundle. Every
build reads the scripts' imports and warns (undeclared-import) about any the bundle doesn't
provide, so a missing dependency shows up before your users find it. If a bundle was built with a
fixed entry point (--entry pkg:main), setting BUNDLEUP_ENTRY=python still lets a setup step
run BUNDLEUP_ENTRY=python python3 bundle.pyz -m ... against the bundled packages.
- Which Pythons: pure-Python packages,
abi3wheels (such ascryptographyororjson), andpy3-none-<platform>wheels (such asplaywrightorruff) run on every Python version in range (requires-pythonintersect wheel tags).uv initwrites the Python it ran on intorequires-python(>=3.13), which keeps every older one out, Apple's 3.9 included: lower it if the scripts run there, anduv lockagain; the build says whenrequires-pythonis what limits the bundle. For packages with per-interpreter compiled extensions (cp3XX, such asgreenlet,lxml,Pillow, ornumpy),bundleup buildautomatically merges the.cpython-3XX/.cp3XXwheels across compatible Python 3 versions (3.9–3.14) into each platform's payload—and whenuv.lockselects different package versions across Python versions (such aspyeeinplaywright), automatically layers the uncovered lock forks so shared files are stored once and the bundle works across every compatible Python 3 version out of the box. A bundle started with another version re-runs itself with a matching Python if one is installed (python3.X, a plainpython3/pythonof the right version such as Apple's/usr/bin/python3, or auv-managed interpreter viauv python find; ADR-0036); otherwise it names the versions it needs.bundleup check --matrixshows which platforms and versions the lock's wheels cover. - Size: each file is stored once across platforms and versions, but compiled packages differ
per platform. Measured 2026-10-07 for python-pptx, lxml, Pillow, PyMuPDF, xlsxwriter and
pywin32 on the four platforms above: 144 MiB for one Python, 230 MiB for three, 314 MiB for
five; PyMuPDF is about half (without it, python-pptx and pywin32 for the same four platforms
make 55 MiB: the
skillCI job builds that, then runs the scripts on Linux, Windows and macOS). Over 100 MB, bundleup warns (large-bundle): GitHub refuses such files. Add--split 100MB(orsplit = "100MB"under[tool.bundleup]): a bundle that doesn't fit becomes a smalldeps.pyzand adeps.pyz.parts/folder beside it, no file over 100 MB, so no Git LFS (ADR-0045). Commit both; SKILL.md's command doesn't change. Each rebuild with new contents commits new parts, and git keeps the old ones: ten rebuilds of a 144 MiB bundle pass GitHub's recommended 1 GB per repository, so rebuild the committed bundle when dependencies change, not on every edit. A missing or mismatched part is refused by name. CI builds the skill with--split 20MBand runs it on all three OSes. Measured again 2026-10-22 (twelfth review): PyMuPDF 2.0 now pulls in onnxruntime, numpy and networkx, so the same skill is 329 MiB for four platforms and one Python. onnxruntime has no wheel for Intel Macs, so--python-platform allfails on it; list the platforms that have wheels, and setMACOSX_DEPLOYMENT_TARGET=14.0for its macOS 14 wheels (the error says so). - Python must be installed for a
.pyz. macOS has one (Apple's 3.9, if the developer tools are installed); many Windows machines don't, andpythonthere may be the Microsoft Store placeholder, which isn't Python. For those, ship executables that bring their own Python (below, "A machine without Python"): one per platform, and SKILL.md names the one to run. - No network, no index, no install step on the user's machine; the first run unpacks into a cache (a second or two for this size), later runs start like an installed venv. When a new version of the skill unpacks, copies of older versions unused for 30 days are removed (macOS and Linux), so updates don't pile up.
Claude API Skills and code execution run in a sandbox with Python 3.11 on Linux x86_64, no
network and no package installs (Anthropic's code execution docs). A skill script that imports
anything outside the standard library and the preinstalled libraries can't run there, and the
uv run the Agent Skills guide recommends needs the network. A bundle carries its dependencies:
bundleup build tool.py --python 3.11 --python-platform x86_64-manylinux_2_28 --max-size 30MB -o my-skill/scripts/tool.pyz--python 3.11 --python-platform x86_64-manylinux_2_28: the sandbox's Python and platform. Its exact glibc isn't documented; 2.28 (2018) is the oldest level that many packages, Pillow 12 among them, still publish wheels for.--max-size 30MB: a Skill must stay under 30 MB; over it, nothing is written.
Then in my-skill/SKILL.md (format):
---
name: my-skill
description: What the skill does and when to use it.
compatibility: Runs scripts/tool.pyz with Python 3.11 on Linux x86_64; needs no network.
---
Run `python scripts/tool.pyz --help` to see the options, then ...The sandbox already has many libraries installed (pandas, numpy, pillow, python-pptx and more, per Anthropic's docs). If your script only needs those, you don't need bundleup there; a bundle always carries its own locked versions, which costs space.
Check it before shipping: bundleup check tool.py --python 3.11 --python-platform x86_64-manylinux_2_28 --strict. If a package has no wheel for the sandbox, the error names the
platforms it does have wheels for. Not yet tested in the real sandbox (it needs an API account).
bundleup build --format zip --python 3.13 --python-platform x86_64-manylinux_2_34 --max-size 50MB --entry app:handler
bundleup build --format zip --python 3.13 --python-platform aarch64-manylinux_2_34 --max-size 50MB # Graviton
bundleup build handler.py --format zip --python 3.12 --python-platform x86_64-manylinux_2_34 --max-size 50MBFor an S3 upload (up to 250 MB unzipped), raise or drop --max-size 50MB.
Pick the platform from the function's runtime (AWS's table, checked 2026-10-06):
| Runtime | OS | --python-platform |
|---|---|---|
python3.12 and newer |
Amazon Linux 2023 (glibc 2.34) | x86_64-manylinux_2_34 or aarch64-manylinux_2_34 |
python3.10, python3.11 |
Amazon Linux 2 (glibc 2.26) | x86_64-manylinux_2_17 or aarch64-manylinux_2_17 |
uv has no manylinux_2_26, so the older runtimes use 2_17, and packages that stopped publishing
2_17 wheels (Pillow 12) can't be built for them; bundleup check says so.
--format zip writes your code and its dependencies at the top of a .zip, as Lambda expects,
with bytecode precompiled for the runtime (Lambda's /var/task is read-only). Set the function's
runtime to the Python you built for and its handler to module.function: bundleup prints it when
you pass --entry app:handler (handler app.handler); for a script handler.py, it's
handler.<function>. Bytecode is hash-checked, so edits made in Lambda's console editor apply.
Tested in CI: every gauntlet project except the .pth one (below) runs inside AWS's own Lambda
image on x86_64 and arm64.
Known limits: Lambda doesn't run .pth files from /var/task (bundleup warns,
pth-not-run), and has no /dev/shm, so multiprocessing.Pool and Queue don't work there
(an AWS limitation; the emulator CI uses has it, so this isn't tested).
A .pyz is one file, so an image only needs a Python of the version it was built for:
FROM python:3.12-slim
COPY dist/app.pyz /app/app.pyz
ENTRYPOINT ["python", "/app/app.pyz"]Build the bundle for the image's platform (--python 3.12 --python-platform x86_64-manylinux_2_28, or aarch64-…). On first start it unpacks into the user's cache; in a
read-only container give it a writable place, and if the bundle has compiled code, one where
code can run: docker run --read-only --tmpfs /tmp:exec …. Docker's plain --tmpfs is mounted
noexec, and a bundle with compiled code refuses it with that reason rather than failing on import.
Tested in CI: a bundle with a compiled extension runs in python:3.12-slim as is, read-only
with /tmp:exec, and refuses clearly with a noexec /tmp (job docker).
To skip the unpacking on every container start, unpack it while building the image: ENV BUNDLEUP_CACHE=/opt/bundleup then RUN python /app/app.pyz --help (any command that starts it).
Set this way, the cache is the only place the bundle looks (ADR-0048), so a read-only container
uses it as is. Not yet tested in CI.
The same file goes into sandboxes built from images (not yet tested there):
- E2B: use the Dockerfile above as the template's
e2b.Dockerfile, then runpython /app/app.pyzin the sandbox. - Modal:
modal.Image.debian_slim(python_version="3.12").add_local_file("dist/app.pyz", "/app/app.pyz"), then runpython /app/app.pyzin a Sandbox or Function. - A setup script (Codex cloud and the like): copy the
.pyzinto the repository; nothing to install, so it also works in the phase without network.
bundleup takes Python projects as they are (ADR-0041); you don't need uv to use it (installing bundleup installs its own).
| You have | Run | What happens |
|---|---|---|
A pyproject.toml you install with pip install . |
bundleup build |
Builds; warns unlocked |
A legacy setup.py / setup.cfg project |
bundleup build |
Builds; warns unlocked |
A folder with main.py, other modules and a requirements.txt |
bundleup build myapp/ |
The folder's files go in as they are (not .venv, caches or build/); runs __main__.py, main.py or app.py, or --entry server.py |
A script with a requirements.txt beside it |
bundleup build tool.py |
Builds; warns unlocked unless every version is pinned |
| A script that needs only the standard library | bundleup build tool.py |
Builds; a script importing an undeclared package is refused, naming it |
A pylock.toml from pip lock . (pip 25.1+) |
bundleup build |
Builds from the lock |
Without a lock, the versions are resolved when you build (for every platform at once, in a
temporary folder: nothing is written into your project), and the bundle's manifest records exactly
what went in. Two builds a week apart can still differ, which is what the unlocked warning
says. To make builds repeatable, lock once:
uv lock(orpip lock .) in a project;- a
requirements.txtthat pins every package it needs (uv pip compile requirements.in -o requirements.txt --generate-hashes, orpip freeze > requirements.txt) counts as a lock; with--hashon every line, uv also checks the hashes; uv add --script tool.py <packages>anduv lock --script tool.pyfor a single script.
--strict fails on the warning; --locked refuses anything that isn't locked.
poetry.lock and Pipfile.lock aren't read: export them (poetry export --with-hashes -o requirements.txt, pipenv requirements --hash > requirements.txt) or build from
pyproject.toml.
bundleup installs exactly the files the lock names, from the index they were locked from, and
checks their hashes; a same-named package on PyPI is never used. So the index matters when you
lock (uv lock, your command), not when you build. The bundle itself never uses the network.
uv reads uv's configuration, not pip's: a pip.conf that points pip at an Artifactory is
ignored. Set the same URL once for uv, in ~/.config/uv/uv.toml (Windows:
%APPDATA%\uv\uv.toml) or a project's pyproject.toml as [[tool.uv.index]]:
[[index]]
url = "https://artifactory.example.com/api/pypi/pypi/simple" # pip.conf's index-url
default = trueor UV_DEFAULT_INDEX=<url> in the environment. bundleup passes its environment to uv, so these
work too:
- Proxy:
HTTPS_PROXY,HTTP_PROXY,NO_PROXY. - A proxy that inspects TLS (a company certificate):
UV_NATIVE_TLS=1uses the system's certificate store, orSSL_CERT_FILE=<bundle.pem>(REQUESTS_CA_BUNDLEandCURL_CA_BUNDLEare also read for--format exe). - Credentials:
~/.netrc, keyring, orUV_INDEX_<NAME>_USERNAME/_PASSWORDfor a named index. A lock never stores them. (Not yet tested against an index that needs a login.) - Python and launcher downloads: to pack an interpreter into
--format exe, bundleup downloads that Python (into its own cache, through uv) and thescie-jumplauncher;UV_PYTHON_INSTALL_MIRRORpoints the Python download at a mirror, andBUNDLEUP_JUMP_MIRRORpoints the launcher download at a mirror orfile://folder (still checked against its pinned SHA-256 hash).
Tested: a script locked against a private index (a flat folder) builds and runs from it
(tests/test_index.py).
Build an executable that carries its own interpreter, one per OS and CPU, from any machine (ADR-0047):
bundleup build --format exe --python 3.12 --python-platform x86_64-pc-windows-msvc -o dist/tool.exe
bundleup build --format exe --python 3.12 --python-platform aarch64-apple-darwin -o dist/tool-macos
bundleup build --format exe --python 3.12 --python-platform x86_64-manylinux_2_28 -o dist/tool-linuxThe first build downloads a small launcher (scie-jump, checked against a pinned hash; mirrorable
via BUNDLEUP_JUMP_MIRROR) and the interpreter for that platform (through uv; mirrorable via
UV_PYTHON_INSTALL_MIRROR), once each. Run it as dist/tool.exe ARGS; with --entry python it
runs scripts as python would (dist/tool.exe scripts/deck_edit.py), and bundleup verify dist/tool.exe checks the launcher, bundled Python archive, trailer, and inner .pyz.
- Size: the interpreter adds 22-35 MB (the skill fixture: 49 MB for macOS, 50 for Windows,
57 for Linux, against 16 MB as a
.pyz). Warm starts equal the.pyz's; the first run unpacks the interpreter too (4.5 s for the fixture on a Mac). - Unsigned. Files delivered by git or a package manager run as they are. On macOS a file
downloaded in a browser is quarantined, and Gatekeeper warns about an unsigned executable;
these can't be signed (data appended to a Mach-O isn't covered by a signature). Windows
antivirus may look twice at an unknown
.exe. Where Python exists, a.pyzavoids all this. - CI builds the skill fixture as three executables on a Mac and runs each on Linux, Windows and
macOS with no Python on
PATH.
Where only a .py (or only text) is accepted, write the bundle as one plain-text Python file
(ADR-0046):
bundleup build tool.py --format py -o dist/tool.py
python3 dist/tool.py --helpThe top of the file says what's inside (each package and version, where it runs) in a
# /// bundleup block, which nothing installs from; the loader follows, then the packages as
base64 comment lines. It runs like the .pyz (any flag that works for a .pyz works, apart from
--split), and bundleup verify dist/tool.py checks it. Best for small tools: it's a third
bigger than the .pyz, and Python reads the whole file on every start, about 2 ms per MB (a
small tool starts 3 ms slower than its .pyz; a 21 MB one 38 ms slower). Every gauntlet project
also runs as a .py (--tool bundleup-py).
bundleup check --python-platform linux --python-platform windows --python-platform macos checks
the first platform and reads the lock to list every package without a wheel for the others, in
milliseconds. On Linux it also says which manylinux level would work.
Splunk apps (bin/lib), QGIS and Maya plugins, Azure Functions' .python_packages: build a
plain directory for the host's Python and platform.
bundleup build --format dir --python 3.9 --python-platform x86_64-manylinux_2_17 -o my_app/bin/libRunning bundleup build again replaces a directory it wrote before; it refuses to replace
anything else. Tested on every push: each gauntlet project runs with only that directory on
PYTHONPATH, on Linux, macOS and Windows.
bundleup check --strict --json > check.json # exit 1 on any error or warning
bundleup build --smoke "--version" # build, then run it once offline in a fresh home
bundleup build --locked # CI implies --locked when there's a lockfile
bundleup verify dist/app.pyz # the bundle matches its manifestDiagnostics have stable codes (syntax-error, data-files, pth-not-run, max-size,
...); branch on code, not on the message. Schemas: docs/schema/.
A bundle unpacks once per machine into a cache. Point the cache at fast local storage (when
BUNDLEUP_CACHE is set it's the only place a bundle looks, so a copy in a home folder on NFS
can't win; ADR-0048), and clean it up later:
BUNDLEUP_CACHE=/local/scratch/$USER python app.pyz
bundleup cache clean --older-than 7Simultaneous first runs on one machine unpack once (16 at once: 0.55 s instead of 3.2 s).