Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Zygo — a warm sandbox per request

CI crates.io PyPI npm Hex Licence: Apache-2.0

Zygo forks a warm, sandboxed interpreter for every request: 1.4 ms through its API, and every request starts from a process that has never served one. Rootless, OCI images, and no daemon to install: the one long-lived process is a supervisor under your own user, not a system service.

This page is the first page of the Zygo book, also on the web at mhmtskrc2.github.io/zygo.

Try it now, once Zygo is installed — one program in a fresh sandbox, thrown away when it exits:

zygo run python:3.12-slim python3 -c 'print("hello")'   # pulls the image the first time

And what Zygo is for — a warm function, forked for every request:

zygo serve ./handler.py --name resize        # a warm zygote: ~150 ms, once
zygo exec resize '{"url": "..."}'            # a fresh, sandboxed process: 2.8 ms (1.4 by API)
zygo exec resize '{"url": "..."}'            # and again, from the same clean copy

Why

A warm worker that serves many requests is fast and dirty: request n sees whatever request n-1 left behind. A container per request is clean and slow. Zygo is the third thing. zygo serve starts an interpreter, lets it do its imports, and parks it inside a sandbox. zygo exec forks it. Each child has its own cgroup, deadline and secrets, and is thrown away afterwards.

The same import-heavy Python script, run fresh for each call on one host (a Linux 6.8 VM, 2 vCPU, aarch64; chapter 25 has the method, and make bench-embed repeats it):

usuallywhat it pays for
docker run --rm542 msdaemon, containerd, shim, runc, a container object to remove
zygo run70.8 msa fresh sandbox — namespaces, cgroup, mounts — in one process
zygo exec2.8 msa fork() of the warm interpreter, plus starting the CLI

Most of the 70.8 ms is Python importing those sixteen modules: the sandbox itself is about 3.6 ms, and python3 -c pass inside one is 12 ms. Through the HTTP API rather than the CLI, a warm request is 1.44 ms usually and 10.5 ms for 1 in 100, and one function sustains 1,108 requests a second. zygo bench all reproduces every number on your own machine.

The zygote idea is older than Zygo: Android starts apps that way, and serverless research forked handlers from a pre-imported process in SOCK (Oakes et al., USENIX ATC 2018) and restored them from a snapshot in Catalyzer (Du et al., ASPLOS 2020). Zygo’s part is the packaging — one static binary, rootless, every limit on, an agent protocol any language can speak — not the idea.

Why not bubblewrap, nsjail, nono, sandbox-runtime or kern?

They are good at what they do, and none of them keeps a sandboxed process warm and forks it per request.

What it isWhat Zygo adds
bubblewrap, nsjailbuilding blocks for one confined processimages, mandatory limits, an egress allowlist — and the warm fork
nono, sandbox-runtimeconfinement for a command you were running anyway (Landlock and seccomp, or bubblewrap and Seatbelt)a sandbox with its own root filesystem and limits, for code you did not write
kerna daemonless, rootless container per call, in a few msa warm interpreter: no interpreter start and no imports on the request path
E2B, Modal, Daytonaa microVM or gVisor per session, in their cloudruns on your hardware, and costs a fork rather than a VM per call
Sandlock, Zeroboota copy-on-write fork of a Landlock-confined process, or of a Firecracker snapshota fork that lands in a sandbox with its own root, pid namespace, cgroup and network — and the tenants, secrets and API around it

Similar projects compares all of them but sandbox-runtime, with measurements against nsjail and kern.

The boundary

The default backend, ns, is the host kernel: namespaces, cgroup v2, a seccomp allowlist, Landlock, no capabilities and a read-only root. That is the right wall for code that is semi-trusted — your customers’ scripts, an agent’s tools. A kernel bug is a way through it, as it is for every container. The same spec also runs on gvisor (a kernel in user space) or vm (libkrun), one-shot only. Neither is a full wall for hostile code yet. Both lack a network. A rootless gvisor cannot enforce its limits, and vm has none inside the guest and a kernel you build yourself. For anonymous code, the honest answer today is a separate machine.

make escape-linux attempts 21 of the vectors in the threat model, with 0 escapes, and every syscall number is swept against the seccomp profiles. The same chapter lists the tenant-against-tenant vectors not attempted yet, and says where the boundary is weaker than it looks. No external audit has been done. Fork safety, question by question covers what a fork shares with its parent and what it does not.

Install

# Linux, x86_64 or aarch64: one static binary, checked against the release's checksums
url=https://github.com/mhmtskrc2/zygo/releases/latest/download
curl -fsSLO "$url/zygo-$(uname -m)-unknown-linux-musl.tar.gz"
curl -fsSL "$url/SHA256SUMS" | sha256sum -c --ignore-missing
tar xzf zygo-*-unknown-linux-musl.tar.gz && sudo install -m 0755 zygo-*/zygo /usr/local/bin/

brew install mhmtskrc2/zygo/zygo      # macOS: the shim, and a Linux VM it manages
cargo install zygo-cli                # from source

Linux needs kernel 5.3 or newer (6.1 recommended), unprivileged user namespaces and delegated cgroup v2. On a Mac every command runs in a Linux VM, about 22 ms away. There is also a signed container image. Getting started covers all of it, signatures included.

zygo doctor                           # can this host run sandboxes? prints the fix if not
zygo run --mem 128M --timeout 10s python:3.12-slim python3 -c 'print("hello")'   # pulls the image
echo 'def handler(event): return {"got": event}' > handler.py
zygo serve ./handler.py --name echo && zygo exec echo '{"n": 1}'   # serve never pulls
zygo stop --all                       # everything serve started; on a Mac, the VM too

What else is in the box

  • One file per project. sandbox.toml declares functions, their images, limits, network and secrets; zygo up deploys it blue/green and pins image digests in zygo.lock. Chapter 20
  • Every limit is on by default — memory, CPU, pids, wall clock, scratch, open files — and the deadline kills the request’s whole process tree.
  • The network is off by default. egress is an allowlist of names; private ranges and the cloud metadata address stay closed. Chapter 14
  • Secrets are files, written from outside the sandbox for one request: never environment variables, never in the warm process’s memory.
  • Any language. Python and Node agents ship; anything else is a fresh process per request in a held sandbox (about 1.4 ms), or an agent of your own against the protocol.
  • For programs: an HTTP API, dependency-free Python and Node clients (zygo-sdk), an Elixir client (zygo_sdk), and an MCP server. Chapter 17
  • For a multi-tenant product: tenants with their own tokens and budgets, a script or a whole workspace (a tar) sent with each request, streamed output, and cancellation — all over the API.

Building on it? Read 13 (warm functions), then 17 (the API, the SDKs, MCP), then 23 (the threat model), in that order.

Status

v0.1.x: one machine; Linux in production, macOS for development. The warm path is ns-only by decision (ADR 0002). ROADMAP.md says what comes next.

Not for: an interactive session or a REPL (a request is one call, not a shell you keep); more than one machine (no scheduler, no cluster); GPUs; Windows without WSL2.

More

Contributing · Security policy · Changelog · Roadmap · Licence: Apache-2.0

Zygo is not affiliated with Zygo Corporation, the metrology company.

The Zygo book

← Previous page: the project README — the book’s first page, and the five-minute version of everything below.

All of Zygo’s documentation, in one place, written to be read from the start.

It begins with Container 101: what the Linux kernel gives you to build a sandbox, and what Docker builds from it. Then it explains Zygo — how it works, where it saves time and memory, and how it compares with everything around it. Then it teaches you to use it, and ends with the full reference: every command, every flag, every field, every file and every exit code. You do not need to know any of it already. If you can use a shell and you know what a process is, you can read every page.

Each section is short on purpose — a few sentences, a picture where one helps, and a link to the detail. Read Parts I and II once; after that, the headings work as a reference. This book is the source of truth: when Zygo changes, the book changes in the same commit (AGENTS.md).

Part I — Container 101

0The project READMEWhat Zygo is, why it exists, and how to try it — the first page.
1The kernel and the processWhat a process is, how one is born, and who may do what.
2NamespacesHow a process gets its own view of the machine.
3Control groupsHow a group of processes gets a limit on what it can use.
4The other locksCapabilities, seccomp, Landlock, the root filesystem, the network.
5DockerWhat a container is, what an image is, and where Docker’s time goes.

Part II — Zygo, explained

6How Zygo worksThe one-shot sandbox, the warm zygote, and the parts around them.
7Where the time and memory are savedEach saving, how big it is, and what it costs.
8The rules Zygo is built onEight principles, and the price of each.
9FreeBSD jails, and ZygoThe older idea, and whether “jails for Linux” is fair.
10Similar projects, and Docker side by sidensjail, bubblewrap, kern, gVisor, Firecracker…; docker run against zygo run, flag by flag.

Part III — Using Zygo

11Getting startedInstall, check the host, run a sandbox, warm a function.
12One-shot sandboxeszygo run, step by step.
13Warm functionsHandlers, warm-exec, runtime pools, and living with them.
14Limits, networking and secretsWhat each limit does, the egress allowlist, secrets as files.
15Images and dependenciesRegistries, venvs, apt layers, bytecode, cleaning up.
16Deploying and running in productionzygo up, watching, upgrading, containers, Kubernetes, capacity.
17The HTTP API, the SDKs and MCPCalling Zygo from a program or an AI agent.
18Writing an agentA warm path for a language of your own.

Part IV — Reference

19Every commandEvery command and flag, with defaults and exit codes.
20sandbox.toml, field by fieldEvery section and field, its default and its rules.
21Environment, files and exit codesEvery variable read, every file written, every way it ends.
22TroubleshootingThe errors people hit, and the fix for each.

Part V — Security and speed

23Security: the threat modelEvery attack, what stops it, and whether a test tries it.
Fork safety, question by questionWhat a forked request shares, what it does not, and where that is weaker than it sounds.
24Seccomp profilesThe three syscall profiles, and which to choose.
25What Zygo costsEvery measured number, and the machine it came from.

Part VI — Decisions

26Why it is built this wayThe design decisions in plain words; the full records are in adr/.
GlossaryEvery term in the book, in one line each.

Where to start

  first time here? ────────────────▶ the README, then this page
  new to containers? ──────────────▶ Part I, then Part II
  know Docker, new to Zygo? ───────▶ chapters 6, 7, 10, then 11
  want to use it today? ───────────▶ chapter 11, then 13
  looking something up? ───────────▶ Part IV
  deciding whether to trust it? ───▶ chapter 23

Three sentences to hold on to

  1. A container is not a thing in the kernel. It is a normal process with several limits put on it; the kernel has no idea what a “container” is.
  2. The limits are cheap; the tools around them are not. Setting up the limits takes about a millisecond. Docker takes hundreds, and most of that is programs talking to programs.
  3. Zygo removes the tools, then removes the start-up. It sets the limits up itself, in one process, and then keeps a ready copy of your program waiting, so a request costs one fork().

How the numbers are used

Every number about Zygo in this book comes from chapter 25, which names the machines and the commands. Numbers about other projects are their own claims or commonly measured ranges, and they are marked that way. zygo bench all repeats Zygo’s numbers on your own machine.

1. The kernel and the process

This chapter is about the basics that come before namespaces and containers: the kernel, system calls, processes, fork and exec, users and root. They are old and simple, and they are the ground everything else stands on. A namespace, a cgroup, a container and a Zygo sandbox are all just new rules added to these same old pieces. If a later chapter feels confusing, the missing piece is usually here.

  chapter 1:  kernel · syscalls · processes · fork/exec · users · capabilities
                  ▲
  chapter 2–4:    │ namespaces, cgroups and filters are rules ON these pieces
                  ▲
  chapter 5:      │ a container = one process + those rules + a file tree
                  ▲
  chapter 6:      │ a Zygo sandbox = the same, with no daemon, kept warm

The kernel and user space

The kernel is the one program that talks to the hardware. It owns the memory, the CPU time, the disks and the network cards, and it decides who gets which. Everything else — your shell, Python, a database — runs in user space and cannot touch the hardware directly. When a program in user space needs something, it has to ask the kernel. That split is the first and oldest boundary on the machine, and every sandbox in this book is a way of making the kernel say “no” more often.

 USER SPACE     ┌───────┐  ┌────────┐  ┌──────────┐  ┌──────────┐
 (programs)     │ shell │  │ python │  │ postgres │  │ your job │
                └───┬───┘  └───┬────┘  └────┬─────┘  └────┬─────┘
                    │ syscalls │            │             │
 ═══════════════════╪══════════╪════════════╪═════════════╪══════ the boundary
                    ▼          ▼            ▼             ▼
 KERNEL         ┌─────────────────────────────────────────────────┐
                │ processes · memory · files · network · devices  │
                └─────────────────────────────────────────────────┘
 HARDWARE          CPU          RAM          disk         network card

System calls

The way a program asks the kernel for something is a system call, or syscall. Opening a file is openat, reading from it is read, starting a process is clone, and there are about 450 of them on a modern Linux. A syscall is a small, well-defined door: the program puts a number and some arguments in place and the kernel answers. Because every request passes through these doors, the kernel can check each one. It is also why the number of doors matters for safety — each is code in the kernel that a hostile program can try to confuse.

Processes

A process is a running program: its memory, its open files, its user, and the kernel’s notes about it. Every process has a number, the pid, and a parent. The first process on the machine is pid 1, and every other one descends from it, so the processes form a tree. ps -ef --forest shows that tree. A sandbox is, at the end, a branch of this tree that the kernel treats differently from the rest.

fork and exec

Linux starts a new program in two steps. fork makes a copy of the calling process — same memory, same open files — and both copies carry on from the same line. exec (execve) then replaces the copy’s memory with a new program loaded from disk. A shell that runs ls forks itself, and the child execs ls. The split looks strange, but it gives you a moment between the two steps where the child can change its own situation — close files, change user, enter a sandbox — before the new program starts. Every sandbox in this book does its work in that moment.

 shell (pid 100)
    │
    │ fork()
    ├──────────────────▶ copy of shell (pid 101)
    │                        │
    │                        │  ◀── the moment: change user, close files,
    │                        │      enter namespaces, join a cgroup,
    │                        │      install filters …
    │                        │
    │                        │ execve("/bin/ls")
    │                        ▼
    │                     ls (still pid 101)
    │                        │ exit
    ▼ wait() ◀───────────────┘

Copy-on-write

A fork does not really copy the memory. Parent and child share the same physical pages, marked read-only, and a page is copied only when one of them writes to it. So forking a 50 MB process takes well under a millisecond and costs almost no new memory. The child still behaves as if it had its own full copy: what it writes, the parent never sees. This one trick is the base of Zygo’s warm path, and chapter 6 comes back to it.

 right after fork                   after the child writes to page B
 ────────────────                   ────────────────────────────────
     parent   child                      parent   child
        │       │                           │       │
        ▼       ▼                           ▼       ▼
     ┌───┬───┬───┐                       ┌───┬───┬───┐   ┌────┐
     │ A │ B │ C │                       │ A │ B │ C │   │ B' │
     └───┴───┴───┘                       └───┴───┴───┘   └────┘
   one set of pages, shared           parent sees  A  B  C
   and marked read-only               child sees   A  B' C   (one page copied)

Users and root

Every process runs as a user, which the kernel knows as a number, the uid. Files have an owner uid and permission bits, and the kernel checks them on every access. uid 0, root, has always been special: for most checks, root simply passes. That made root the prize of every attack, because one bug in a root program gave away the whole machine. Much of the history of Linux security is the slow work of making root less all-powerful.

Capabilities

Capabilities split root’s power into about forty named pieces. CAP_NET_ADMIN lets a process change the network, CAP_SYS_ADMIN lets it mount file systems and much more, CAP_KILL lets it signal anyone. A process can hold some pieces and not others, and it can drop the ones it does not need, for good. A sandbox usually drops all of them. Zygo does: a program inside it has an empty capability set.

Everything is a file, and /proc

Linux shows a lot of its state as files. /proc is a file system the kernel makes up on the fly: /proc/1234/ describes process 1234, /proc/meminfo describes memory. /sys does the same for devices and, as chapter 3 shows, for control groups. This matters for sandboxes in two ways. Many controls are set by writing a small file, which is fast and needs no special tool. And a sandbox must be careful what parts of /proc and /sys it shows, because some of those files are doors of their own.

One kernel

Here is the fact the rest of the book keeps coming back to. On one Linux machine, every process — in a container or not — talks to the same kernel. Namespaces, control groups and filters are all rules inside that kernel. If the kernel itself has a bug that lets a process break its rules, every rule fails at once. That is why some sandboxes add a second kernel (gVisor) or a whole virtual machine (Firecracker, Zygo’s vm backend); chapter 10 compares them.

  containers / Zygo `ns`        gVisor                   microVM (Firecracker, Zygo `vm`)
  ──────────────────────        ──────                   ────────────────────────────────
  ┌─────┐ ┌─────┐ ┌─────┐       ┌─────┐                  ┌─────┐          ┌─────┐
  │ app │ │ app │ │ app │       │ app │                  │ app │          │ app │
  └──┬──┘ └──┬──┘ └──┬──┘       └──┬──┘                  └──┬──┘          └──┬──┘
     │       │       │          ┌──▼─────────────┐       ┌──▼────────┐    ┌──▼────────┐
     │       │       │          │ user-space     │       │ guest     │    │ guest     │
     │       │       │          │ kernel         │       │ kernel    │    │ kernel    │
     │       │       │          └──┬─────────────┘       └──┬────────┘    └──┬────────┘
  ┌──▼───────▼───────▼──┐       ┌──▼─────────────┐       ┌──▼─────────────────▼───────┐
  │     host kernel     │       │  host kernel   │       │  host kernel + KVM         │
  └─────────────────────┘       └────────────────┘       └────────────────────────────┘
  one kernel bug = all out      a bug must pass two      a bug must pass the guest
                                                         kernel AND the hardware wall

2. Namespaces

A namespace changes what a process can see. It does not limit how much it can use — that is control groups, the next chapter.

What a namespace is

Normally every process sees the same machine: the same list of processes, the same files, the same network, the same host name. A namespace gives a group of processes their own copy of one of those things. Inside a new PID namespace, for example, a process sees only its own children and thinks it is pid 1. Nothing is copied and nothing is emulated; the kernel just keeps a separate table and answers from it. Linux has eight kinds, and each one can be used alone or with the others. A “container” is simply a process that has its own copy of most of them.

            the host sees                         the sandbox sees
 ┌──────────────────────────────────┐     ┌─────────────────────────────┐
 │ pids   1 systemd … 4200 zygo     │     │ pids   1 python   2 sh      │
 │        4201 python  4202 sh      │     │                             │
 │ files  /home /etc /var …         │     │ files  / of the image only  │
 │ net    eth0 wlan0 lo  + LAN      │     │ net    lo                   │
 │ name   my-laptop                 │     │ name   sandbox              │
 │ users  0 root … 1000 you         │     │ users  0 root  (= you, 1000)│
 └──────────────────────────────────┘     └─────────────────────────────┘
        same processes 4201 and 4202 — two different views of them
NamespaceIts own copy ofSince
mountthe list of mounted file systems2.4.19 (2002)
UTShost name2.6.19 (2006)
IPCshared memory, message queues2.6.19
PIDprocess numbers2.6.24
networkinterfaces, addresses, routes, firewall2.6.29
useruser and group numbers3.8
cgroupthe view of the cgroup tree4.6
timeboot-time clocks5.6

Mount namespace

The mount namespace gives a process its own list of mounted file systems. It can mount and unmount things, and the rest of the machine does not see it. This was the first namespace, added in 2002, and it is why the flag is still called just CLONE_NEWNS — nobody expected others. A sandbox uses it to build a whole new file tree from an image, then switch its root to that tree, so the host’s files are simply not there. Chapter 4 explains how the switch is done.

PID namespace

The PID namespace gives a process its own process numbers. The first process inside becomes pid 1, and it can only see and signal processes in the same namespace, or in namespaces below it. From outside, the same processes are still visible, with their normal host numbers. So a sandboxed program cannot list, watch or kill anything on the host. When pid 1 of the namespace exits, the kernel kills everything else inside it, which makes clean-up easy.

Network namespace

The network namespace gives a process its own network: its own interfaces, its own addresses, its own routes and its own firewall rules. A new one has only a loopback interface (lo), so by default the process can reach nothing but itself. To give it more, something outside has to connect it — a virtual cable to the host, or a program that moves packets in user space. Chapter 4 covers both, and Zygo’s choice.

UTS namespace

UTS is an old name for “the system’s name”. This namespace gives a process its own host name and domain name. It is the smallest of the eight and hides no secret; it mostly stops a program from learning the host’s name, or changing it. Container tools set it so that hostname inside prints something sensible.

IPC namespace

IPC means “inter-process communication”. This namespace gives a process its own System V message queues, shared memory segments and POSIX message queues. Without it, two programs on one machine could meet through a shared memory segment with a guessable key. It is old and rarely thought about, but a sandbox that skips it leaves a small side door open.

User namespace

The user namespace gives a process its own list of users. Inside it, a process can be uid 0 — root — while outside it is still your own normal uid. A uid map (/proc/<pid>/uid_map) says which inside number matches which outside number. Root inside a user namespace has full capabilities, but only over things that belong to that namespace: its own mounts, its own network, never the host’s. This is the namespace that lets a normal user create all the others, and it is what makes rootless containers — and all of Zygo — possible.

  inside the sandbox          uid_map            on the host
  ──────────────────     ─────────────────     ──────────────
  uid 0    "root"   ───▶   0     1000   1  ───▶  uid 1000 (you)
  uid 1000 "app"    ───▶   (a second line, via newuidmap, if the host allows)
  any other uid     ───▶   not mapped: shows as "nobody", owns nothing

  root inside  = every capability over the sandbox's own mounts, network, …
               = no power at all over anything that belongs to the host

Cgroup namespace

The cgroup namespace changes how a process sees the control-group tree from chapter 3. Inside it, the process’s own group looks like the top of the tree, so it cannot learn where it sits on the host. It hides information; it does not set any limit. Zygo creates one, and in addition does not mount the cgroup file system inside the sandbox at all.

Time namespace

The newest one, added in Linux 5.6. It lets a process see a different value for the clocks that count time since boot. Its main use is moving a running container to another machine without its clocks jumping. Sandboxes for short jobs rarely need it, and Zygo does not create one.

Creating and joining

Three syscalls do the work. clone (and the newer clone3) starts a child in new namespaces. unshare moves the calling process into new namespaces. setns joins a namespace that already exists, given a file that points to it, such as /proc/<pid>/ns/net. Zygo uses clone3 to build a sandbox, because it makes the child pid 1 of its new PID namespace in one step. It uses setns on the warm path, to put a new process into a sandbox that is already built.

  clone3(flags)            unshare(flags)            setns(fd)
  ─────────────            ──────────────            ─────────
  parent                   process                   process      existing
    │                        │                         │          namespace
    └─▶ child, born         moves itself into         └──────────▶ ┌─────┐
        inside NEW          NEW namespaces                         │ net │
        namespaces                                                 └─────┘
  used by Zygo to          used by `unshare(1)`      used by Zygo to enter
  BUILD a sandbox          and many tools            a WARM sandbox

What namespaces do not do

Namespaces hide things; they do not count or limit anything. A process in its own namespaces can still use all the memory, fill the process table with a fork bomb, or keep every CPU busy. Namespaces also do nothing about which syscalls a process may call, so the whole kernel is still in reach. Those gaps are filled by control groups (chapter 3) and by seccomp and Landlock (chapter 4). You need all of them together; any one alone is not a sandbox.

Seeing them yourself

lsns lists the namespaces on the machine. ls -l /proc/self/ns shows the ones your shell is in, each as a number. unshare --user --map-root-user --pid --fork --mount-proc bash starts a shell where you are root and pid 1 — without being root on the host. Type ps inside it and you will see two processes. That small command is most of the idea behind every tool in this book.

3. Control groups

Namespaces change what a process can see. Control groups, or cgroups, limit what it can use.

What a cgroup is

A cgroup is a group of processes that the kernel counts and limits together. You can say “this group may use 256 MB of memory, half a CPU and at most 64 processes”, and the kernel holds every process in the group to it. A child process starts in its parent’s group, so a program cannot escape its limit by forking. Without cgroups, one bad program could use up the whole machine; with them, it can use up only its own share.

The tree, as files

Cgroups are shown as folders under /sys/fs/cgroup. Each folder is a group, and a folder inside it is a smaller group inside the bigger one. You create a group with mkdir, move a process into it by writing its pid to cgroup.procs, and set a limit by writing to a file such as memory.max. There is no special tool and no daemon; it is plain file work. Limits nest: a group can never use more than its parent allows, whatever its own files say.

/sys/fs/cgroup/                                 the whole machine
├── system.slice/                               system services
└── user.slice/
    └── user-1000.slice/
        └── user@1000.service/                  ◀── handed to you (delegation)
            └── zygo.slice/                     memory.max = RAM − reserve
                ├── system/                     the supervisor, protected
                └── tenants/
                    └── acme/                   one customer's budget
                        └── resize/             one function: cpu.max, pids.max
                            └── g4242-1/        one warm sandbox
                                ├── zygote      the warm process: memory.max = mem
                                ├── req-01f3…   one request ─┐ each has its own group and
                                └── req-01f4…   one request ─┘ its own memory.max = mem

This is Zygo’s real tree; crates/zygo-core/src/cgroup.rs describes each level. The memory limit is on the leaves on purpose. The function’s group holds the warm process and every request at once, so a memory.max there would be one budget for all of them, and a single request going over it would take the others with it. On its own group, a request that goes over mem is killed alone, and the warm process and the requests beside it go on.

Controllers

Each kind of resource is handled by a controller. The ones a sandbox cares about most are these:

ControllerLimitsExample file
memoryRAM, and what happens when it runs outmemory.max
cpuCPU time, as a share or a hard quotacpu.max
pidshow many processes and threadspids.max
iodisk reads and writesio.max

When a group goes over memory.max, the kernel’s OOM killer (“out of memory”) kills a process in that group — not somewhere else on the machine. When it hits pids.max, fork just fails, which is how a fork bomb is stopped.

Version 1 and version 2

Linux has two versions of cgroups. Version 1 had a separate tree for each controller, which was flexible but confusing, and hard to hand to a normal user safely. Version 2 has one tree for everything, with clearer rules. Most current distributions use version 2 only. Zygo needs version 2; zygo doctor checks for it and says so if it is missing.

Delegation: cgroups without root

The cgroup tree belongs to root, so how can a normal user create groups? The answer is delegation: the system gives a user one branch of the tree, and the user can do what they like inside it. On a systemd machine every login already has such a branch, under user@<uid>.service. Zygo creates its whole tree inside that branch, which is why it needs no root at all. Troubleshooting covers the machines where the branch is missing or missing a controller.

Killing a whole group

Killing one process is easy; killing everything it started is not, because a process can fork faster than you can list its children. Cgroup version 2 has a file for this, cgroup.kill: write 1 to it and the kernel kills every process in the group at once (Linux 5.14 and newer). Zygo uses it for every deadline: when a request runs out of time, one file write ends the request and all its children, and nothing is left over.

  req-01f3/                      echo 1 > req-01f3/cgroup.kill
  ├── python  (the request)
  │   ├── sh                     ─▶  all of them, gone in one step,
  │   │   └── curl                   even ones forked a moment ago
  │   └── python (a worker)
  └── … (a fork bomb in progress)

Counting, not only limiting

A cgroup also keeps numbers. memory.peak says the most memory the group ever used, memory.events says whether the OOM killer fired, and cpu.stat says how much CPU was used and how often the group was held back by its quota. Zygo reads these after each request to report why it ended: out of time, out of memory, or neither. Its benchmark also reads cpu.stat, and refuses to judge a latency number when the group was held back, because that number would describe the limit rather than the code.

Freezing

The cgroup.freeze file stops every process in a group without killing it, and a second write lets them carry on. The processes keep their memory; they simply get no CPU time. Zygo uses this to pause a warm function that has been idle for a while. Waking it again costs one write, much less than starting it over.

Namespaces and cgroups, side by side

NamespacesCgroups
Question they answerWhat can this process see?How much can this process use?
Unitone kind of thing: mounts, pids, network…one group of processes
Stop a fork bomb?noyes, pids.max
Hide the host’s files?yes, mount namespaceno
Needed for a sandboxyesyes

Neither one limits which syscalls a process may call. That is the next chapter.

4. The other locks

Namespaces decide what a process sees, and cgroups decide how much it uses. This chapter is about the rest: what it is allowed to do, and what its world is built from.

Why more locks are needed

A process in its own namespaces, inside a tight cgroup, can still call almost every syscall the kernel has. Some of those syscalls are large and complex, and most serious container escapes of the last ten years went through one of them. So a sandbox adds locks that work at a finer level: which powers the process holds, which syscalls it may make, and which files it may touch. Each lock is simple on its own. Together they mean that one mistake is not enough to get out.

  ┌──────────────────────────────────────────────────────────────┐
  │ namespaces        what it can SEE                            │
  │  ┌────────────────────────────────────────────────────────┐  │
  │  │ cgroup          how much it can USE                    │  │
  │  │  ┌──────────────────────────────────────────────────┐  │  │
  │  │  │ capabilities + no_new_privs  what POWER it holds │  │  │
  │  │  │  ┌────────────────────────────────────────────┐  │  │  │
  │  │  │  │ Landlock      which FILES / PORTS          │  │  │  │
  │  │  │  │  ┌──────────────────────────────────────┐  │  │  │  │
  │  │  │  │  │ seccomp     which SYSCALLS           │  │  │  │  │
  │  │  │  │  │         ┌──────────────┐             │  │  │  │  │
  │  │  │  │  │         │ your program │             │  │  │  │  │
  │  │  │  │  │         └──────────────┘             │  │  │  │  │
  │  │  │  │  └──────────────────────────────────────┘  │  │  │  │
  │  │  │  └────────────────────────────────────────────┘  │  │  │
  │  │  └──────────────────────────────────────────────────┘  │  │
  │  └────────────────────────────────────────────────────────┘  │
  └──────────────────────────────────────────────────────────────┘
      to get out, a program has to beat every layer — or the kernel itself

Dropping capabilities

As chapter 1 said, capabilities are root’s power cut into pieces. Inside a user namespace a process can hold all of them — over that namespace. A sandbox then drops every one it does not need, from every set the kernel keeps, so they cannot come back. Docker keeps fourteen by default, to be useful to normal software. Zygo keeps none, because a function has no need to change the network or create device files.

no_new_privs

Some programs on disk are marked setuid: they run as their owner, often root, whoever starts them. sudo and passwd work this way. Inside a sandbox, that would be a way to gain power back. The no_new_privs flag, set once on a process, tells the kernel that nothing this process or its children exec may ever gain power, setuid or not. It cannot be unset. Every serious sandbox sets it, and seccomp needs it before a normal user may install a filter.

seccomp

seccomp (“secure computing”) lets a process install a small filter that the kernel runs on every syscall it makes. The filter is a tiny program, written in classic BPF, that looks at the syscall number and its arguments and answers “allow”, “fail with an error” or “kill”. Once installed it cannot be removed, and children inherit it. Docker’s default filter lists what is blocked and allows about 350 syscalls. Zygo’s lists what is allowed — about 215 names, of which 190 exist on an arm64 kernel and all on x86_64 — so a syscall added to the kernel next year is blocked until someone chooses to allow it. Seccomp profiles has the full lists.

  program ── syscall(nr, args) ──▶ ┌─────────────────────────┐
                                   │ seccomp filter (BPF)    │
                                   │  read, write, openat …  │──▶ allow ──▶ kernel does it
                                   │  clone + CLONE_NEW*     │──▶ EPERM ──▶ "not permitted"
                                   │  bpf, io_uring, ptrace, │──▶ EPERM
                                   │  mount, unshare …       │
                                   │  anything not listed    │──▶ EPERM
                                   └─────────────────────────┘

Landlock

Landlock, added in Linux 5.13, lets a normal process limit its own access to files, and from 6.7 its network connections too. The process says, for example, “from now on I may read under /usr and write under /tmp, and nothing else”, and the kernel enforces it, even against root inside the sandbox. Like seccomp, it can only be tightened, never loosened. It is a second wall behind the mount namespace: if a mistake ever made a host path visible inside, Landlock would still refuse to open it. Zygo applies it wherever the kernel has it.

AppArmor and SELinux

These are security modules: rules, loaded by the system’s administrator, that the kernel checks for every process. Docker on Ubuntu loads an AppArmor profile for each container; on Fedora and Red Hat, SELinux labels do the same job. They are strong, but they need root to set up, so a rootless tool like Zygo cannot use them for its own sandboxes. They can get in its way, though: Ubuntu’s AppArmor rules limit unprivileged user namespaces, and troubleshooting explains the fix.

Resource limits (rlimits)

rlimits are the old, per-process limits that came before cgroups: how many files a process may have open, how big a file it may write, how much stack it may use. You see them with ulimit -a. They are weaker than cgroups because they count per process, not per group. They are still useful for the few things cgroups do not cover, such as open files. Zygo sets nofile (1024 by default) this way.

The root filesystem

Inside a mount namespace a sandbox builds a new file tree, usually from an image, and makes it the root. The old way is chroot, which only changes where path lookups start and has well-known ways out. The better way is pivot_root: it swaps the old root for the new one in the mount namespace, and then the old root can be unmounted, so the host’s files are not just hidden but gone from this view. Zygo uses pivot_root, mounts the root read-only, and gives the sandbox one small writable place, /tmp, in memory.

Layers: overlayfs, bind mounts and tmpfs

Three kinds of mount do most of the work. overlayfs stacks folders on top of each other and shows them as one, which is how an image made of several layers becomes one tree without copying anything; a normal user may use it from Linux 5.11. A bind mount shows an existing file or folder at a second place — this is how your code gets into a sandbox, and it can be made read-only. tmpfs is a file system in memory, which vanishes when the last process using it exits. Build the root from overlayfs, add your files with bind mounts, give it a tmpfs to write to, and you have a container’s file system.

  what the program sees as /             where it really comes from
  ──────────────────────────             ──────────────────────────
  /app/handler.py   (read-only)   ◀───── bind mount of ./handler.py on the host
  /tmp              (writable)    ◀───── tmpfs, in memory, 64M, gone at exit
  /usr /lib /bin …  (read-only)   ◀───── overlayfs of the image's layers:
                                           ┌───────────────────────┐
                                           │ layer 3  pip packages │
                                           │ layer 2  python       │
                                           │ layer 1  debian base  │
                                           └───────────────────────┘
                                           stored once, shared by every sandbox

The network

A new network namespace has only loopback. There are two common ways to connect it. The first is a veth pair: a virtual cable with one end inside and one on the host, joined to a bridge with NAT — Docker’s way, which needs root on the host side. The second is a program that moves packets between the namespace and the host’s normal sockets, in user space and as your own user; slirp4netns and pasta do this. Zygo uses pasta, and puts an nftables firewall inside the sandbox’s own namespace, where your user is allowed to. That firewall is how “this function may reach api.example.com:443 and nothing else” is enforced.

  Docker (bridge, root on the host)            Zygo (pasta, your own user)
  ─────────────────────────────────            ───────────────────────────
  ┌ container netns ┐                          ┌ sandbox netns ─────────────┐
  │ eth0            │                          │ tap0                       │
  └──┬──────────────┘                          │ nftables: allow only       │
     │ veth pair                               │   api.example.com:443      │
  ┌──▼──────────────┐                          │ no 10.x / 192.168.x /      │
  │ docker0 bridge  │ ◀─ iptables NAT          │   169.254.169.254          │
  └──┬──────────────┘                          └──┬─────────────────────────┘
     ▼                                            │ packets as data
  host network: LAN, cloud metadata,           ┌──▼──────────────┐
  the internet — all reachable by default      │ pasta (as you)  │──▶ normal sockets
                                               └─────────────────┘    on the host

Putting it together

A sandbox on Linux is all of these, set up in the right order in the moment between fork and exec:

clone3 into new namespaces (user, mount, pid, net, ipc, uts, cgroup)
  → write the uid map                     (from the parent)
  → join a cgroup with limits
  → build the root: overlayfs + bind mounts + tmpfs, then pivot_root
  → set rlimits, drop every capability, set no_new_privs
  → install Landlock, then the seccomp filter
  → execve your program

Each step is cheap — a namespace set is about a millisecond, a cgroup write a tenth of that, a filter microseconds. The whole list is the sandbox. The rest of this book is about who runs this list, how often, and what they put around it.

5. Docker

Docker did not invent any of the parts in the last three chapters. What it did was put them behind one simple command, and add a way to ship software that the whole industry now uses.

The problem Docker solved

Before 2013, putting a program on a server meant installing its language, its libraries and its settings on that server, and hoping they matched the developer’s machine. Docker’s answer was to ship the program with its whole file system, as one thing you can copy, and to run it in namespaces so it did not see or break the rest of the server. “It works on my machine” became “it works in this image”. The isolation was a side benefit; the packaging was the revolution.

Images and layers

An image is a file system plus a little metadata: which command to run, which user, which environment. It is stored as a stack of layers, each a tar file of changes on top of the one below. Two images based on the same python:3.12 share those lower layers on disk and on the network. A layer is named by the hash of its content, so it can never change without getting a new name. That is why an image pulled today by digest is the same, byte for byte, as the one pulled next year.

        myapp:1.0                  otherapp:2.3
    ┌─────────────────┐        ┌─────────────────┐
    │ COPY . /app     │        │ COPY . /app     │   each layer is named by
    ├─────────────────┤        ├─────────────────┤   the hash of its content:
    │ pip install …   │        │ pip install …   │   sha256:9f…, sha256:77…
    └────────┬────────┘        └────────┬────────┘
             └────────────┬─────────────┘
                 ┌────────▼────────┐
                 │ python:3.12     │   shared: stored and
                 ├─────────────────┤   downloaded once
                 │ debian:bookworm │
                 └─────────────────┘

Why a sandbox brings its own files

A common first question: Python is already installed on my machine, so why does a container need an image with another Python in it? The answer is that a program is never one file. /usr/bin/python3 needs libpython, the C library, about a thousand files of standard library, SSL certificates, time zone data and more, spread over /usr, /lib and /etc. To run the host’s Python inside a sandbox, you would have to show the sandbox all of those host folders — and hiding the host’s files is the sandbox’s first job. An image solves this: it is a complete, separate set of files, so the sandbox can see everything it needs and nothing of the host.

  USING THE HOST'S PYTHON                     USING AN IMAGE
  ───────────────────────                     ──────────────
  ┌ sandbox ────────────────────────┐         ┌ sandbox ────────────────────────┐
  │ /usr/bin/python3  ◀── host      │         │ /usr/bin/python3  ◀── image     │
  │ /usr/lib/python3.12 ◀── host    │         │ /usr/lib/python3.12 ◀── image   │
  │ /lib (libc, libssl) ◀── host    │         │ /lib (libc, libssl) ◀── image   │
  │ /etc (certs, config) ◀── host   │         │ /etc (certs, config) ◀── image  │
  │ app.py                          │         │ app.py                          │
  └─────────────────────────────────┘         └─────────────────────────────────┘
  the sandbox can read big parts of           the sandbox sees a complete world,
  the host, and they change with apt          and none of the host's files

An image is also a promise

There is a second reason, just as important. The Python on your laptop is 3.12, the one on the server is 3.10, and apt upgrade can change either one tomorrow. A program that works on one machine may fail on the other, and you cannot tell why. An image named by its digest is the same bytes on every machine, today and next year. And an image costs almost nothing per run: it is downloaded once, stored once, and shared read-only by every container that uses it.

The Dockerfile

A Dockerfile is a recipe for building an image, one step per line: FROM python:3.12, RUN pip install requests, COPY . /app. Each step runs in a temporary container and its changes become a new layer. It is simple and very widely known. The cost is that every change of a dependency means a new build, a new image and a new push. Chapter 6 shows how Zygo avoids this.

OCI: the standards

The Open Container Initiative turned Docker’s formats into open standards. The image spec says how an image and its layers are stored; the distribution spec says how a registry serves them; the runtime spec says how a folder plus a config.json becomes a running container. Because of OCI, an image built by Docker runs under Podman, Kubernetes, or Zygo, and comes from any registry: Docker Hub, GitHub, your own. Zygo reads OCI images and uses none of Docker’s own code.

A container is a process

Run docker run -d nginx and then ps -ef on the host: nginx is there, as a normal process with a normal pid. There is no box and no small machine. The kernel sees a process with its own namespaces, in a cgroup, with a seccomp filter and an AppArmor profile — exactly the list at the end of chapter 4. “Container” is a word for that process plus the metadata Docker keeps about it.

The chain behind docker run

docker itself does almost nothing. It sends your request over a socket to dockerd, the daemon, which runs as root. dockerd asks containerd to create the container. containerd starts a containerd-shim, which runs runc. runc does the real work — namespaces, cgroup, mounts, filters — then execs your program and exits. The shim stays, for as long as the container lives, to hold its input, output and exit code.

docker ─▶ dockerd ─▶ containerd ─▶ containerd-shim ─▶ runc ─▶ your program
 (CLI)    (root)      (root)        (stays)           (exits)

Five programs and three hand-overs for one command. Each exists for a good reason — upgrades without stopping containers, Kubernetes, many clients — and each costs time.

Where Docker’s time goes

docker run of a small Python script takes about 300 to 1000 milliseconds. The isolation itself — the part runc sets up — is around one millisecond of that. The rest is the chain above, creating the container’s record and its writable layer, setting up the network bridge, and then starting Python and its imports from nothing. docker exec into a running container skips some of this and still costs 50 to 100 ms. For a service that runs for weeks, none of it matters. For a function that runs for 10 ms, it is almost all of the cost.

  one `docker run` of a 10 ms Python function, roughly

                ~250 ms           ~1 ms         ~220 ms         ~10 ms
  ┌────────────────────────────────┬─┬───────────────────────────┬──┐
  │ docker → dockerd → containerd  │▒│ Python starts and imports │██│
  │ → shim; record, layer, network │▒│ its modules from nothing  │██│
  └────────────────────────────────┴─┴───────────────────────────┴──┘
  ├────────────┼────────────┼─────────────┼────────────┼────────────┤
  0           100          200           300          400         500 ms

  ▒  the isolation itself
  ██ your code

What a container leaves behind

A container is an object, not only a process. When the program exits, the container stays in docker ps -a, with its writable layer on disk and its logs in the daemon, until someone runs docker rm, or passed --rm. This is right for services: you can restart them, read their logs, step inside. For a job that runs once, it is something to clean up.

Docker’s defaults

Docker’s defaults are made for running software you chose. The root file system is writable, fourteen capabilities are kept, the process runs as root inside, the network is a bridge that can reach your local network, and there is no memory, CPU or process limit unless you ask. You can make a container very tight — --read-only --cap-drop ALL --network none --memory ... — but you have to know every flag. For running code you did not write, the defaults point the wrong way. The comparison lists them side by side with Zygo’s.

Rootless Docker and Podman

The root daemon has always been Docker’s weak spot: access to its socket is the same as root on the host. Rootless Docker runs the whole chain as a normal user inside a user namespace. Podman goes further: no daemon at all, each container started by the podman command itself, with a small helper, conmon, staying behind like the shim. Both are real steps forward. Both still create container objects and still start your program from nothing, so the per-run cost stays in the hundreds of milliseconds.

What Docker is for

Docker is the right tool for building images, running long-lived services, connecting them with networks, publishing ports and restarting them when they fail. Zygo does none of that, and uses Docker’s images happily. The question this book cares about is narrower: what if the thing you run is a short function, called thousands of times, written by someone you do not fully trust? The next chapter is the answer Zygo gives.

6. How Zygo works

Zygo uses the same kernel parts as Docker. What it changes is who sets them up, how often, and what is already waiting when a request arrives.

Zygo in one picture

                    ┌──────────────────────── zygo (your user, no root) ──────────────────────────┐
                    │                                                                             │
   zygo run ───────▶│  ONE-SHOT: build a sandbox ─▶ run the program ─▶ exit, nothing left  ~12 ms │
                    │                                                                             │
   zygo serve ─────▶│  WARM: build a sandbox once, start the interpreter, import the handler      │
                    │        and park it as a "zygote"                                   ~150 ms  │
                    │                                                                             │
   zygo exec ──────▶│        fork the zygote ─▶ the child runs one request ─▶ exit        ~1.4 ms │
   zygo exec ──────▶│        fork the zygote ─▶ the child runs one request ─▶ exit        ~1.4 ms │
                    │                                                                             │
                    └─────────────────────────────────────────────────────────────────────────────┘

There are two halves. The one-shot half is a better docker run: a fresh sandbox per program, built in one process. The warm half is the reason the project exists: a sandbox that is built once and then copied for every request. The rest of the chapter takes them in that order.

The one-shot sandbox: zygo run

zygo run python:3.12 python3 app.py does the whole list from chapter 4 itself. It reads the image from its own store, works out a mount plan, calls clone3 into seven new namespaces, and the child builds its root, joins its cgroup, drops every capability, installs Landlock and seccomp, and calls execve. There is no daemon, no RPC, and no container record. When the program exits, the kernel removes the namespaces and the tmpfs, Zygo removes the cgroup, and nothing is left to clean up. With the image already pulled, this takes a median of 12 ms on a Linux 6.8 VM. That is python3 -c pass. The 70.8 ms on the README’s table is the same command running a script that imports sixteen modules: the sandbox costs the same 3.6 ms, and the rest is Python doing the imports — the work a warm function does once (chapter 25).

  docker run                                   zygo run
  ──────────                                   ────────
  docker ─▶ dockerd ─▶ containerd              zygo
             ─▶ shim ─▶ runc ─▶ program          └─ clone3 ─▶ child: mounts, cgroup,
                                                              caps, Landlock, seccomp
  record + layer left: docker rm                              ─▶ execve ─▶ program
  300–1000 ms                                  nothing left · ~12 ms

The idea of a zygote

The name comes from Android. Starting a Java app from nothing is slow, so Android starts one process, the Zygote, loads the common libraries into it, and then forks it every time an app opens. Each app gets a ready-made process in a few milliseconds, and the loaded libraries are shared through copy-on-write. Zygo applies the same trick to a sandbox. The expensive part of running a Python function is not the sandbox, it is Python itself — starting the interpreter and importing modules often takes hundreds of milliseconds. So Zygo does that once, inside the sandbox, and forks the result.

The same idea reached serverless before Zygo. SOCK (Oakes et al., SOCK: Rapid Task Provisioning with Serverless-Optimized Containers, USENIX ATC 2018) forks Python handlers from a zygote that has already imported their packages, and Catalyzer (Du et al., Catalyzer: Sub-millisecond Startup for Serverless Computing with Initialization-less Booting, ASPLOS 2020) restores a function from a snapshot instead of starting it. Zygo’s contribution is the packaging — one static binary, rootless, every limit on, an agent protocol any language can speak — not the idea.

The warm path

zygo serve ./handler.py builds a sandbox, starts a small agent inside it, and the agent imports your handler and waits. That waiting process is the zygote. For each request the zygote calls fork(); the child runs your handler(event) once, sends back the result, and exits. The child starts with everything already loaded, because it is a copy of a process that had loaded it. And it leaves nothing behind, because it is thrown away.

            ┌──────────────── one warm sandbox (namespaces, root, filters) ─────────────────┐
            │                                                                               │
            │   zygote: python + imports done + handler loaded — never runs a request       │
            │     │                                                                         │
 request 1 ─┼──▶  ├── fork ─▶ child 1: handler(event) ─▶ reply ─▶ exit  (its writes: gone)  │
 request 2 ─┼──▶  ├── fork ─▶ child 2: handler(event) ─▶ reply ─▶ exit  (starts clean)      │
 request 3 ─┼──▶  └── fork ─▶ child 3: handler(event) ─▶ reply ─▶ exit  (starts clean)      │
            │                                                                               │
            └───────────────────────────────────────────────────────────────────────────────┘

Why a fork is clean

A normal worker process that serves many requests slowly collects state: a global that one request changed, an open connection, a patched function, a file in /tmp. Request n runs in whatever request n−1 left. Zygo’s children never share that problem, because each one is a copy of the zygote, and the zygote has never served a request. Files are the one place that needs more than a fork: each request gets its own temporary folder, named by TMPDIR and removed afterwards, because the sandbox’s /tmp is shared by every request in it. So a request sees exactly what the zygote had after its imports — every time, whatever the requests before it did. You get the speed of a shared worker and the cleanliness of a fresh container.

  a shared worker                          zygo exec
  ───────────────                          ─────────
  worker ─ req 1 ─ req 2 ─ req 3 ─ …       zygote ──┬─ copy ─ req 1 ✗
     state piles up: req 3 sees                     ├─ copy ─ req 2 ✗
     what req 1 and 2 left behind                   └─ copy ─ req 3 ✗
                                           every copy starts from the same clean point

One cgroup per request

Each child is put in a cgroup of its own, under its function’s group — you saw the tree in chapter 3. That gives every request its own memory limit, process limit and CPU share. When a request runs past its deadline, one write to its cgroup.kill ends it and every process it started, and the zygote keeps serving. When it runs out of memory, the kernel kills that request, not the zygote and not a neighbour. After each request Zygo reads the group’s numbers and reports the result: timed_out, oom_killed, peak memory, wall time.

Warm-exec, for programs that start fast

A Go or Rust binary starts in a millisecond, so there is nothing to keep warm inside it. For these Zygo keeps only the sandbox warm: namespaces, mounts and filters built once. Each request is a new process that enters the sandbox with setns and execs your cmd, reading the event on standard input and writing the result on standard output. This costs about 1.4 ms, measured with sh -c cat as the program; a bigger program adds its own start. It works for any language and any image, with no agent at all.

warm-exec (cmd)agent (entry)
What is kept warmthe sandboxthe sandbox and the loaded interpreter
A request isa new process entered into the sandboxa fork() of the zygote
Overhead~1.4 ms + the program’s own start~1.4 ms
Best forGo, Rust, C, shellPython, Node, anything with slow start-up

The agent and its protocol

The agent is the small program inside the sandbox that loads the handler, forks, and passes events and results. It talks to Zygo over a simple, documented wire protocol (spec/protocol.md), not through a library. So any language can have an agent: Zygo ships one for Python and one for Node, and zygo agent test checks a new one against the same conversation. A runtime pool is the same idea with no handler loaded in advance: the script arrives with the request, which lets one warm zygote serve thousands of different scripts for about 0.5 ms more.

The supervisor

Someone has to keep zygotes alive, hand out requests, watch deadlines and collect results. That is the supervisor: a normal process under your own user, which the first zygo serve starts when it needs one. It is not a system service and it never runs as root. It owns the cgroup tree, keeps a reserve of memory for itself so that busy tenants cannot starve it, and serves the CLI, the HTTP API and the SDKs. If it goes, the sandboxes it started go with it; nothing is left running on its own.

  CLI · HTTP API · Python/Node SDK · MCP
                  │
          ┌───────▼────────┐    memory reserved,
          │   supervisor   │    so tenants cannot starve it
          │  (your user)   │
          └──┬─────┬─────┬─┘
             │     │     │   deadlines, cgroups, secrets, logs
      ┌──────▼┐ ┌──▼────┐ ┌▼──────┐
      │zygote │ │zygote │ │sandbox│   one per function (and script version)
      │resize │ │fetch  │ │parse  │
      └───────┘ └───────┘ └───────┘

Images without a Dockerfile

Zygo pulls normal OCI images from any registry, into a store under your home folder. What you would put in a Dockerfile goes in sandbox.toml instead: requirements = "./requirements.txt" becomes a Python venv, built once inside a sandbox with the image’s own pip, and system = ["libwebp7"] becomes an extra layer with those apt packages. Each is keyed on the image digest and the list, built once, and shared by every function that asks for the same thing. The image itself is never changed, and there is no build step for you to run or to push.

   python:3.12-slim  (from the registry, never changed)
          │
          ├── + bytecode layer   (.pyc for the standard library, built once)
          ├── + system layer     (apt: libwebp7)        key: image digest + list
          └── + /venv            (pip: requirements)    key: image digest + lockfile
                     │
            shared by every function that names the same image and the same list

Why not zygo ./venv/bin/python app.py?

It looks simpler: you already have a Python and a venv, so why name an image at all? Because a venv is not a Python. venv/bin/python is only a link to the host’s interpreter, and the venv folder holds only the extra packages; the interpreter, its standard library and the C libraries under it all stay in the host’s /usr and /lib. To run it, Zygo would have to show the sandbox those host folders, which is exactly what a sandbox exists to hide (chapter 5). So Zygo has no mode that uses the host’s own files as the root, on purpose: every sandbox starts from an image, and sees of your machine only what you --mount, read-only unless you say :rw.

  what you think a venv is          what a venv really is
  ────────────────────────          ─────────────────────
  ┌ venv/ ──────────────┐           ┌ venv/ ──────────────┐
  │ python              │           │ bin/python ─────────┼──▶ /usr/bin/python3.12   (host)
  │ everything it needs │           │ lib/…/site-packages │      ├─▶ /usr/lib/python3.12/ (host)
  └─────────────────────┘           └─────────────────────┘      └─▶ /lib/libc.so …       (host)

Why not zygo /usr/bin/python3 app.py?

Then skip the venv and point at the system’s own Python directly? It fails for the same reason, and trying it shows why, one wall at a time. The file /usr/bin/python3 is small; the Python it starts is not. On an Ubuntu 24.04 machine it needs five shared libraries from the host’s /lib, and then its standard library — 1,194 files, 29 MB, in the host’s /usr/lib/python3.12. Mount only the binary into an image, and each missing piece stops it in turn.

  zygo run --mount /usr/bin/python3:/opt/python3 IMAGE /opt/python3 -c "print(1)"

  wall 1  the loader    alpine:3               "the program does not exist"   exit 125
          (musl image, and the binary asks for glibc's loader)
  wall 2  the libraries debian:bookworm-slim   "libexpat.so.1: cannot open    exit 127
                        python:3.12-slim        shared object file"
          (the image has glibc, but not the exact libraries this build wants)
  wall 3  the stdlib    python:3.12-slim       "No module named 'encodings'"  exit 1
                        + libexpat mounted too
          (it looks for /usr/lib/python3.12; the image keeps its own elsewhere)

Measured on the Lima VM from chapter 25, Ubuntu 24.04, Python 3.12.3, aarch64. Notice the last row: even an image with the same Python version does not help, because the host’s binary looks for the host’s files, in the host’s places.

And if you mounted all of it?

You could keep mounting — /usr/lib/python3.12, then the libraries, then /etc/ssl — until it starts. By then the sandbox can read most of the host’s /usr and /lib: every program installed, every version, which is a map for anyone looking for a weak spot. Worse, those files are not yours to hold still: the next apt upgrade replaces them under a zygote that is still running, and a warm function breaks in a way nobody can reproduce. The system Python is also the operating system’s own tool — apt and other system programs depend on it, and on Debian and Ubuntu pip refuses to install into it (the “externally managed environment” error). The image’s Python belongs to your function alone, and never changes unless you change its name.

  the system's Python                     the image's Python
  ───────────────────                     ──────────────────
  owned by the OS, used by apt            owned by your function
  changes with every apt upgrade          changes only when you change its name
  different on every machine              the same bytes everywhere (a digest)
  shows the sandbox the host's /usr       shows the sandbox nothing of the host

What you do instead, for Python

You keep the parts that are yours and take the rest from an image. Your code comes in with --mount ./app.py:/app/app.py. Your packages come from --requirements requirements.txt: Zygo builds a venv once, inside the image, with the image’s own pip, and shares it with every run that asks for the same list. The Python itself comes from the image, the same bytes on every machine. Nothing here costs time per request: the image is pulled once, the venv is built once, and both are mounted read-only in a few milliseconds.

# not:  zygo ./venv/bin/python app.py
zygo run --mount ./app.py:/app/app.py --requirements requirements.txt \
    python:3.12-slim python3 /app/app.py

Why not zygo ./my_executable_binary?

Now take a compiled program instead — a Go server, a C tool. There is no interpreter this time, so does it still need an image? It depends on how the program was linked, which means how it finds the library code it uses. A dynamically linked program keeps only its own code; when it starts, a small loader (/lib64/ld-linux-x86-64.so.2) finds libc.so and friends on the machine and joins them in. That is the default for C (gcc app.c), and for Go when it uses cgo. Such a program has exactly the venv’s problem: its libraries live in the host’s /lib, and the sandbox is not meant to see it. A statically linked program carries every library inside its own file, so it needs almost nothing — but “almost” is not “nothing”.

  dynamically linked (gcc app.c)          statically linked (CGO_ENABLED=0 go build)
  ──────────────────────────────          ─────────────────────────────────────────
  ┌ my_app ───────┐                       ┌ my_app ────────────────────────┐
  │ your code     │──▶ ld-linux.so (host) │ your code                      │
  └───────────────┘──▶ libc.so.6  (host)  │ + the Go runtime               │
                   ──▶ libssl.so  (host)  │ + every library it uses        │
                                          └────────────────────────────────┘
  needs the host's /lib inside            still wants a few files around it:
  the sandbox: the venv problem again     /etc/ssl/certs, /usr/share/zoneinfo, …

What a static binary still needs

Even a fully static program expects a small world around it. To call an HTTPS API it reads CA certificates from /etc/ssl/certs; to show local time it reads /usr/share/zoneinfo; to turn a uid into a name it reads /etc/passwd; to resolve a host name it reads /etc/resolv.conf; if it runs sh -c, it needs a /bin/sh. And every sandbox needs a root to stand on: somewhere to put /tmp, /proc and /dev. An image gives all of this in a few megabytes, the same on every machine, downloaded once. So Zygo keeps one rule with no exceptions: the root always comes from an image, and from your machine a sandbox sees only what you --mount.

What you do instead, for Go and C

Mount the binary into a small image and name it as the command. Which image depends on how the binary was linked, and file ./my_app tells you: it says either statically linked or dynamically linked, interpreter ….

Your binaryBuild it like thisRun it in
Go, staticCGO_ENABLED=0 go build -o my_appalpine:3 (about 3.5 MB) — or any image
C, staticgcc -static -o my_app app.c (or musl-gcc -static)alpine:3 — or any image
Go or C, dynamic, built on Debian/Ubuntu (glibc)go build with cgo, gcc app.ca glibc image: debian:bookworm-slim
Go or C, dynamic, built on Alpine (musl)the same, on Alpinealpine:3
file ./my_app                              # "statically linked"? then any small image works
zygo run --mount ./my_app:/app/my_app alpine:3 /app/my_app --port 8080

# a dynamic glibc binary: pick an image with glibc, not Alpine
zygo run --mount ./my_app:/app/my_app debian:bookworm-slim /app/my_app

The mistake to avoid is a glibc binary in alpine:3: it fails with “the program does not exist”, because the loader it asks for is not in the image (chapter 12). And for a binary you call again and again, do not pay even the 12 ms of a fresh sandbox: keep the sandbox warm with cmd, and each call costs about 1.4 ms plus the program’s own start (warm-exec).

[fn.parse]
image  = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"]   # a static Go or C binary you built
cmd    = ["/app/parse"]                  # event on stdin, JSON result on stdout

Secrets

A secret, such as an API key, is never put in the environment and never in the zygote’s memory. While a request runs, the supervisor writes it as a file, /run/secrets/NAME, from outside the sandbox, readable only inside that function’s sandbox, and removes it when the function’s last request ends. A request that is compromised can read the secrets its own function was given, while it runs, and never one that another function uses.

The network, off by default

A sandbox starts with no network at all. network = "egress" with allow = ["api.example.com:443"] opens exactly that name and port: Zygo runs its own small DNS resolver inside the sandbox, and when a name on the list is asked for, its addresses are added to the nftables filter before the answer goes back. A name not on the list does not even resolve. Private addresses and the cloud metadata address stay closed in every mode unless you ask for them by a flag named for exactly that. Chapter 14 has the details.

Safe by default, loosened by name

Every limit has a value even if you set none: 256 MB of memory, one CPU, 64 processes, 30 seconds, up to 64 MB of scratch, 1024 open files. The root is read-only, no capability is kept, and the seccomp allowlist is on. To remove a limit you type a flag whose name says what it does — --allow-unlimited, --allow-host-net, --allow-private-net. Docker makes you add safety one flag at a time; Zygo makes you remove it one flag at a time. The principles gives the principle and its cost.

Three backends, one command

isolation = "ns" | "gvisor" | "vm" moves the wall without changing anything else. ns is everything above: the host kernel with its locks, and the only backend with warm functions. gvisor puts gVisor’s user-space kernel between your program and the host. vm boots a small virtual machine with its own kernel through libkrun, for code you trust least, at about six times the start-up cost. ADR 0002 explains why the warm path stays on ns.

On a Mac

Sandboxes are a Linux feature, so on macOS Zygo starts and manages a small Linux virtual machine through Lima. The zygo command on your Mac forwards every sandbox command into it, with the same arguments, folder and streams. Crossing into the VM adds about 22 ms per command; from a program, through the API or the SDKs, the millisecond warm path is still there.

7. Where the time and memory are saved

Zygo is not faster because it found a faster kernel feature. It is faster because it stops doing work that does not need to be done on every request. This chapter lists each piece of that work, how much it costs, and what Zygo does instead.

The whole picture

  what one request pays, for a small Python function (not to scale)
  ─────────────────────────────────────────────────────────────────────────────
  docker run   ░░░░░░░ chain ░░░░░░░▒▓▓▓▓ Python + imports ▓▓▓▓█   300–1000 ms
  docker exec  ░░ daemon ░░█                                        50–100 ms, shared state
  zygo run     ▒▓▓▓▓ Python + imports ▓▓▓▓█                         12 ms, Python included
  zygo exec    ▪█                                                   1.4 ms, clean state
  ─────────────────────────────────────────────────────────────────────────────
  ░ programs talking to programs   ▒ the isolation itself (about the same everywhere)
  ▓ interpreter start-up           ▪ a fork            █ your code

The isolation is not what costs. Each saving below removes one of the other blocks.

Saving 1: no chain of programs

docker run passes through a CLI, a root daemon, containerd, a shim and runc, with a socket or a new process at each step, and each of them keeps its own records. zygo run is one process that makes the syscalls itself. There is nothing to ask and nothing to wait for. This alone takes a one-shot sandbox from hundreds of milliseconds down to about 12 ms with Python in it. The sandbox itself — the namespace set, the cgroup and the mounts — is about 3.6 ms of that, work the kernel has to do whoever asks for it; the other 8 to 9 ms is Python starting (chapter 25).

Saving 2: no container object to create or remove

Docker makes a record for every container, a writable layer on disk, and log files, and later has to delete them. Zygo makes none of these. The root is read-only and shared; the one writable place is a tmpfs that disappears when the process does. There is no zygo rm because there is nothing to remove. On a busy machine this also saves disk writes and the slow build-up of old containers that someone has to clean.

Saving 3: the interpreter starts once, not per request

This is the big one. A Python function that imports a few modules needs roughly 100 to 500 ms before it can run a single line of your code. A container per request pays that every time, however fast the container is. A Zygo zygote pays it once, at zygo serve, and every request after that is a fork() of a process where it is already done: about 1.4 ms. The same holds for Node with a large dependency tree, or any runtime whose start-up is slow.

  per-request cost, same handler, same host (from the embedder's benchmark)
  ────────────────────────────────────────────────────────────────────────
  a container per request   ████████████████████████████████████████  542.4 ms
  a one-shot sandbox        █████▏                                     70.8 ms
  a warm fork               ▏                                           2.8 ms

The one-shot row is still slow here because it starts Python each time, and the warm row includes starting the zygo CLI itself — through the API it is closer to the 1.4 ms above. The embedder’s benchmark has the full setup.

Saving 4: memory is shared, not copied

A forked child shares every page of the zygote until it writes to one (chapter 1). A request that reads a lot and writes little costs very little new memory. The zygotes also share with each other: a hundred warm Python scripts on the same image share almost all of the interpreter’s pages through the image’s files. Measured, each warm script costs about 11 MB of its own memory, not the 21 MB its process seems to use, so around 300 warm scripts fit in 4 GB. ADR 0005 has the numbers.

Saving 5: compiled bytecode is built once

The official python:*-slim images ship no compiled .pyc files, and a read-only root means Python cannot save the ones it compiles. So every run compiled every module it imported again — import ssl alone took 66 ms. Zygo compiles the standard library once, into a layer of its own, the first time it sees such an image. Importing ten common modules went from 165 ms to 35 ms. What Zygo costs has the details.

Saving 6: no image builds for dependencies

With Docker, a new Python package means a new Dockerfile step, a build, a new image and often a push. With Zygo, you list the packages and it builds a venv once, keyed on the image and the exact list, and shares it with every function that asks for the same thing. Changing a function’s code touches no image at all. This saves the build time, the registry space, and the “which image has which version” work that grows with every function.

Saving 7: clean-up is one write

Killing a process tree safely is hard: children can fork while you list them. Zygo puts each request in its own cgroup and ends it with one write to cgroup.kill. No scanning, no race, no leftover process. A timeout is cheap, certain, and it never touches the zygote or another request.

Saving 8: nothing to run or guard

There is no root daemon to keep running, patch, watch and protect. Access to Docker’s socket is the same as root on the host; Zygo has no such socket. The supervisor is a normal process under your user, started when needed. That is a saving in people’s time, not in milliseconds — but on a real team it is often the largest one.

The advantages, in one table

What you getWhere it comes from
Speed~1.4 ms per warm request; ~12 ms per fresh sandboxno chain of programs, and a fork instead of a start
Clean staterequest n cannot see anything request n−1 didevery request is a copy of a zygote that never served one
A limit per requestmemory, CPU, processes and a deadline for each request, not each containerone cgroup per request
Densityhundreds of warm functions per machinecopy-on-write sharing between and inside zygotes
Safe defaultsno network, read-only root, no capabilities, every limit setthe defaults are chosen for code you did not write
No root, no daemonnothing to install as a service, nothing to protect as rootuser namespaces and delegated cgroups
No buildsdependencies declared, built once, sharedvenvs and derived layers keyed on content
One wall, three strengthsns, gvisor or vm with the same spec and commandbackends behind one flag

What is not saved

Your own code costs what it costs; Zygo only removes the work around it. A warm function uses memory while it waits — about 11 to 21 MB for a Python zygote — and after cold_after it is dropped and the next request pays the warm-up again. The ns backend shares the host’s kernel, so a kernel bug still defeats it, as it defeats every container. And Zygo is one machine: it does not spread work across servers, publish ports, or run long-lived services. The threat model and the comparison say where the edges are.

8. The rules Zygo is built on

Zygo is built on eight rules, or principles. Each one is a choice, and each choice has a cost. This chapter puts the cost right next to the rule, because a rule whose cost is hidden is only a slogan.

The eight rules at a glance

The rules fall into three groups: how a sandbox is made, how it stays fast, and how it stays safe and honest. The table is the short version; the rest of the chapter takes them one by one.

RuleWhat it costs
P1A sandbox is a locked-down process, not a containerthe wall is the Linux kernel; one kernel bug breaks every lock
P2The sandbox waits warma warm function uses memory while it waits
P3Isolation is one flagvm is slower and does less; gvisor is one-shot only
P4OCI images, no Dockerfilethe first build of a derived layer takes about five seconds
P5No root, no system servicethe host must have a few programs installed
P6Safe by defaultthe first thing a new user hits is a limit
P7Every limit is requireddisk I/O and bandwidth have no default
P8Honest about what is not proventhe status text says “not built” often
  ┌──────────── how it is made ────────────┐   ┌──────────── how it is fast ────────────┐
  │ P1 one process, no container machinery │   │ P2 the sandbox is ready before a call  │
  │ P4 any OCI image, dependencies in spec │   │ P3 one flag picks where the wall is    │
  │ P5 your user, no root, no root service │   │                                        │
  └────────────────────────────────────────┘   └────────────────────────────────────────┘
  ┌──────────────────────────── how it stays safe and honest ─────────────────────────────┐
  │ P6 locked by default · P7 every limit has a value · P8 every claim measured or marked │
  └───────────────────────────────────────────────────────────────────────────────────────┘

P1: a sandbox is a locked-down process, not a container

A container in Docker’s sense is an object managed by a chain of programs. Zygo skips that chain. It builds the sandbox from kernel parts — namespaces (separate views of the system), a cgroup (a group with resource limits), seccomp (a filter on system calls) and Landlock (a limit on which files a process may touch) — inside one process, with no RPC (a call to another program over a socket). You met all of these in chapters 2 to 4.

The steps are short. Zygo calls clone3 to make a child process in seven new namespaces. The child calls pivot_root to make the image its root folder, drops all its capabilities (pieces of root’s power), installs a BPF filter (the small program that seccomp runs on each system call), and calls execve to become your program. There is no daemon (a long-running background service) to talk to, no shim (a helper process that babysits a container), and no trip to an image service while a request waits.

  zygo ──clone3──▶ child in 7 new namespaces
                     │
                     ├─ pivot_root onto the image
                     ├─ drop every capability
                     ├─ install the seccomp BPF filter (and Landlock)
                     └─ execve ─▶ your program
  no daemon · no shim · no RPC · no image service on the way

What it costs. The wall is the Linux kernel. A bug that lets a program gain kernel privileges defeats every lock at once. That is why the vm backend exists, for code you did not write. zygo doctor and the security chapter say this plainly.

P2: the sandbox waits warm

Nothing is built while a request waits. There are two ways Zygo does this. For a compiled program the sandbox is held: namespaces, mounts and locks are set up once, and each request is a fresh process that enters them. That costs about 1.4 ms, plus the program’s own start. For an interpreter such as Python, the sandbox holds an agent, a small program that has already imported your handler. Each request is a fork() of it — a copy of the process. The copy is copy-on-write: memory is shared until one side changes it, so nothing is copied up front. And because each request is a fresh process, no state carries over from the last one.

Measured: usually 1.4 ms, on the machines named in chapter 25. That is at a steady 250 requests a second, the rate the latency test holds so the host is never the limit. The most one warm function sustained, with four clients sending, is 1,108 a second.

  compiled program (warm-exec)            interpreter (agent)
  ────────────────────────────            ───────────────────
  held sandbox                            held sandbox + agent with handler loaded
     │                                       │
     ├─ new process enters ─▶ run ~1.4 ms    ├─ fork ─▶ handler(event) ~1.4 ms
     └─ new process enters ─▶ run            └─ fork ─▶ handler(event)

What it costs. A warm function stays in memory. A Python zygote (the loaded process that is forked for each request) uses about 20 MB, of which about 11 MB is its own and the rest is shared with other zygotes. The idle rules pause it after idle_timeout: it is frozen but still in memory. They drop it after cold_after, and the next request then pays the warm-up again.

  serving ──(no calls for idle_timeout)──▶ paused: frozen, still ~20 MB in RAM
     ▲                                          │
     │                                    (no calls for cold_after)
     │                                          ▼
     └──────── next call pays warm-up ─── dropped: memory freed

P3: isolation is one flag

isolation = "ns" | "gvisor" | "vm". The spec, the command and the protocol stay the same; only the backend changes, and the backend decides where the wall is drawn. ns uses the host kernel. vm uses KVM (the kernel’s built-in support for virtual machines) with a guest kernel of its own. gvisor uses a kernel that runs in user space.

  isolation = "ns"       your program ─▶ host kernel
  isolation = "gvisor"   your program ─▶ gVisor (user-space kernel) ─▶ host kernel
  isolation = "vm"       your program ─▶ guest kernel ─▶ KVM ─▶ host kernel

What it costs. ns and gvisor are built. vm boots a guest and runs one-shot sandboxes, at about six times the setup cost of ns (422 ms against 73 ms on a Raspberry Pi 5, chapter 25). The guest writes to a private layer bounded by scratch; it has no network and no warm functions. zygo backend install gvisor downloads the gVisor runtime. The same command run on both backends gives the same answer, while uname -r inside reports 4.19.0-gvisor instead of the host’s kernel — which is the point.

gvisor is one-shot only so far. A warm function must be entered for each request; on ns that is setns into namespaces the supervisor holds, and gVisor’s wall is not those namespaces. So Zygo refuses a warm function on gvisor instead of quietly running something weaker, and it refuses a networked sandbox there too. Rootless runsc (gVisor’s runtime) also cannot write cgroups, so limits there are only advisory, and Zygo says so on every start. For a warm function today, that means ns.

P4: OCI images, no Dockerfile

Any OCI image (the standard image format Docker also uses) from any registry can be the file system. What a Dockerfile would add — Python packages, apt packages — goes in the spec instead. Zygo builds it once, inside the image, as a venv (a Python folder of installed packages) or as a derived layer (an extra image layer made by running the install). Every function that names the same thing shares the result. The image itself is never changed.

  image (never changed) ─┬─▶ + venv from requirements.txt ──▶ mounted at /venv
                         └─▶ + derived layer from apt list ──▶ shared by all who ask
  built once, keyed on the image digest and the list

What it costs. A derived layer is a copy of the image, an install, and a diff; about five seconds the first time. Overlayfs (a file system that stacks folders) would be faster, but rootless overlayfs needs kernel 5.11, and not every host Zygo runs on has it.

P5: no root, no daemon

Zygo needs no root at any point and no system service. The supervisor is a process in your own login session that zygo serve starts when it needs one. Outbound networking uses pasta (a user-space network helper) and nftables (the kernel’s packet filter) inside the sandbox’s own namespace. This works because entering a user namespace you created gives you a full set of capabilities inside it, and nowhere else.

  your login session (your uid, no root)
  └─ zygo serve ─▶ supervisor
                    └─ sandbox: own user namespace = full caps HERE only
                         ├─ nftables rules for the allowlist
                         └─ pasta ─▶ the outside network

What it costs. Some features need a program on the host: pasta and nft for outbound networking, and newuidmap for a separate range of user ids per tenant. If one is missing, Zygo refuses to start and names the package. It never silently falls back to something weaker.

P6: safe by default

With no flags, the network is off, the root file system is read-only, the capability set is empty, the seccomp allowlist is on, and every limit has a value. To loosen any of it you must type a flag: --allow-host-net, --allow-private-net, --allow-unlimited. zygo up does not accept these flags, so a spec that needs one has to be served on purpose.

  default                         to loosen, you type
  ───────                         ───────────────────
  network off                ──▶  --allow-host-net, --allow-private-net
  seccomp allowlist on       ──▶  a looser profile, by name
  every limit has a value    ──▶  --allow-unlimited
  (none of these flags work with zygo up)

What it costs. The first thing a new user hits is a limit. The error names the field and the fix.

P7: every limit is required

Memory, CPU, process count, wall-clock time, scratch space, open files, and, for a networked function, connections: each has a default, and there is no “unlimited” without the flag. Disk I/O and bandwidth are the two exceptions, below. The deadline kills the request’s whole process tree (the process and every child it started), not a single process. The per-request cgroup is what makes that one write, to its cgroup.kill file.

  request cgroup ── cgroup.kill ◀── one write at the deadline
     ├─ handler process        ✗
     │   └─ child it spawned   ✗
     └─ another child          ✗   the whole tree ends; the zygote keeps serving

What it costs. Disk I/O and bandwidth have no default limit, because a good value depends on the device. Zygo warns when either is missing.

P8: honest about what is not proven

Every claim in the README is either measured or marked. The test suites try the thing instead of reading a setting. A negative check first proves that the thing really ran. A latency number says whether it hit a limit. Chapter 25 records what was measured and on what hardware. Before these rules existed, several checks passed — or failed — for the wrong reason.

  weak test                           Zygo's test
  ─────────                           ───────────
  read a setting ─▶ pass              1. a negative check proves the probe really ran
                                      2. attempt the forbidden thing ─▶ must fail
                                      3. a latency number says if it hit a limit

What it costs. The status text is long, and it says “not built” more often than a launch page would.

The two warm modes, side by side

P2 has two shapes. Warm-exec keeps only the sandbox ready and runs your cmd fresh each time. Agent mode also keeps a loaded interpreter ready and forks it. Chapter 13 shows how to write each one.

warm-exec (cmd)agent (entry)
What is warmthe sandboxthe sandbox and a loaded interpreter
A request isa fresh process entered into the sandboxa fork() of the agent
Overhead~1.4 ms + the program’s own start~1.4 ms
Needsnothing: any image, any languagean agent that speaks the protocol; Python and Node ship (in agents/); a POSIX sh one is in examples/agents/sh
Use whenthe runtime starts fast: Go, Rust, C, shstarting the runtime is the cost: Python with imports, a JVM, Node with a dependency tree
  warm-exec:  [ sandbox ready ] ──▶ start your program ──▶ run   (fast for Go, Rust, C)
  agent:      [ sandbox ready + Python loaded ] ──▶ fork ──▶ run (skips the slow start)

9. FreeBSD jails, and Zygo

Linux was not first. FreeBSD had a working “container” in 2000, thirteen years before Docker, and many ideas in this book are easier to see there.

Where jails came from

Jails arrived in FreeBSD 4.0, in March 2000, written by Poul-Henning Kamp for a hosting company that wanted to give each customer “root” without giving them the machine. The paper that describes them has a telling title: Jails: Confining the omnipotent root. The idea was to take chroot, which only changes where file paths start, and close every way out of it. A jail became one clear thing in the kernel: a group of processes with its own files, its own host name, its own addresses, and a root user who is not really root.

How a jail works

A jail is one object in the kernel, made with the jail(2) syscall, usually through the jail(8) tool and a config file, /etc/jail.conf. Every process carries a pointer to its jail, and the kernel checks that pointer wherever it matters. A jailed process sees only processes in its own jail. Root inside may not mount file systems, load kernel modules, change the network, or reach raw devices. jls lists the jails; jexec runs a command inside one.

  FreeBSD: one kernel object                  Linux: many parts, put together
  ──────────────────────────                  ───────────────────────────────
  ┌──────────── jail ────────────┐            mount ns ─┐
  │ root path   /jails/web       │            pid ns   ─┤
  │ host name   web.example      │            net ns   ─┤
  │ addresses   10.0.0.5 / vnet  │            uts ns   ─┤
  │ root is limited              │            ipc ns   ─┼─▶  what a tool
  │ processes see only the jail  │            user ns  ─┤    calls a
  │ limits via rctl              │            cgroup   ─┤    "container"
  └──────────────────────────────┘            caps     ─┤    or "sandbox"
       made by jail(2), by root                seccomp  ─┤
                                               Landlock ─┘

What jails have grown since

Jails kept growing. VNET (since FreeBSD 12 in the default kernel) gives a jail a whole network stack of its own, like a Linux network namespace. rctl limits a jail’s memory, CPU and process count, like cgroups. Jails can nest inside other jails. Capsicum, a separate FreeBSD feature, lets a process lock itself down to only the files and sockets it already holds — close in spirit to seccomp and Landlock together. Tools such as iocage and Bastille manage jails the way Docker manages containers, and Podman now runs OCI images on FreeBSD with jails underneath.

One idea, two designs

FreeBSD and Linux solved the same problem in opposite ways. FreeBSD made one strong, complete object: you ask for a jail and you get all of it. Linux made many small parts — each namespace, cgroups, seccomp — and left it to tools to put them together. The FreeBSD way is easier to reason about: there is one thing to check, and it is hard to forget a piece. The Linux way is more flexible — a tool can take only a network namespace, or only a cgroup — but every tool must assemble the parts correctly, and a missing part is a hole. Much of Zygo’s test suite exists because of that second fact.

What Zygo shares with a jail

At the level of “what does the program inside see”, a Zygo ns sandbox and a jail are very close. Both give a group of processes its own root file tree, its own view of processes, its own network (or none), its own host name, and a root user without real power. Both share the host’s kernel, so both are only as strong as that kernel. Both are cheap: no second kernel, no virtual hardware. And both come from the same instinct: the process, not a machine, is the thing to confine.

Where they differ

FreeBSD jailZygo sandbox (ns)
What it isone kernel objecta process with many Linux locks on it
Who can create onerootany user (user namespaces + delegated cgroups)
Users insidethe host’s own uids; jail root is uid 0, limitedits own uids, mapped to yours; root inside is you outside
Usual lifelong: a web server, a mail server, for monthsshort: one program, or one request, then gone
Syscall filternone per jail (Capsicum is per process)a seccomp allowlist on every sandbox
File access rulesthe jail’s root paththe root, plus Landlock as a second wall
Limitsrctl, optionalcgroups, mandatory, one set per request
Imagesa folder or ZFS dataset you prepareOCI images from any registry
Warm startnone: you start processes in the jaila zygote, forked per request in ~1.4 ms
Network defaultthe addresses you give itnothing at all

The biggest difference: who holds the key

On FreeBSD, making a jail needs root, and root inside a jail is the host’s uid 0, only with fewer rights. The safety comes from the kernel’s list of what jailed root may not do. On Linux with a user namespace, “root” inside is just your user outside, so there is nothing of the host’s to lose even if a check were missed. That is why Zygo can run without any privilege, and why a normal user can start a thousand sandboxes without asking an admin. The cost is on the other side: user namespaces open a lot of kernel code to normal users, which is exactly why Zygo’s seccomp filter refuses to let a sandbox create new ones.

Can Zygo be called “jails for Linux”?

Partly — and it is worth being exact. As a picture it fits well: a program locked in its own small world, sharing the kernel, cheap to make. People who know FreeBSD will understand Zygo’s ns backend in one sentence that way. But a jail is usually a long-lived home for a service, built by an admin, and Zygo is the opposite: short-lived, built by any user, often one per request, and it does not run services at all. The honest phrase is “a throwaway jail for every request”: the jail’s walls, with the lifetime of a function call.

Other relatives

FreeBSD was not alone. Solaris Zones (2005) took the same idea further, with its own resource controls and a strong admin model. On Linux, Linux-VServer and OpenVZ were jail-like kernel patches used by hosting companies long before namespaces were finished. LXC (2008) was the first widely used tool to build jail-like “system containers” from the new Linux parts, and Docker began as a layer on top of it. Every one of these, like Zygo, shares one kernel between everything it hosts.

10. Similar projects, and Docker side by side

Many projects build a sandbox from the parts in chapters 2 to 4. They differ in three questions, and once you ask those, most comparisons answer themselves. This chapter asks them, sets docker run and zygo run side by side in detail, and then walks through the projects one by one.

The three questions

  1. Where is the wall? The host kernel with locks on it, a second kernel in user space, or a virtual machine with its own kernel.
  2. What is ready when a request arrives? Nothing — the sandbox is built each time; the sandbox — a new process enters it; or the sandbox and the loaded program — a copy is forked.
  3. Who has to be root? The tool, a daemon, the admin once, or nobody.

Numbers for Zygo in this chapter are the project’s own measurements; chapter 25 names the hosts and the commands. The claims about other projects are theirs or commonly measured, not measured here. They all move quickly; check before quoting.

The map

The three questions give every project a place. The table below puts the first two on a grid: each row is where the wall is, each column is what is already waiting when a request comes in. Read a column from left to right as “less work per request”: on the left everything is built for each call, on the right the program is already loaded and only copied.

  what is ready when a request arrives?

  NOTHING                    THE SANDBOX                 THE SANDBOX + THE PROGRAM
  ───────                    ───────────                 ─────────────────────────
  build the sandbox          enter the sandbox           fork the loaded program
  start the program          start the program           run the request
  run the request            run the request
  tear it all down

  most tools work here       `docker exec`, warm-exec    Zygo `exec`; Sandlock, Zeroboot
  slowest per call                                       fastest per call
Wall ↓ · Ready on arrival →nothing: build it allthe sandbox: enter itthe program: fork it
Virtual machineFirecracker, Kata, microsandbox, Zygo vm—Firecracker from a memory snapshot (a whole VM per restore); Zeroboot (a copy-on-write fork of one)
Second kernelgVisor, Zygo gvisor——
Host kernelDocker, Podman, runc, nsjail, bubblewrap, firejail, minijail, kern, Zygo rundocker exec (state is shared), Zygo warm-execZygo exec
Process confinement onlynono, Landlock-based tools: they confine a process you already runSandlock (a copy-on-write fork of a confined Python process)

The right-hand column held research when Zygo started, not a product: SOCK (USENIX ATC 2018), built into the OpenLambda research platform, forks Python handlers from a zygote on the host kernel (chapter 6). That column is the space Zygo was built for. Since 2026 two more projects fork there, each from a different row: Sandlock and Zeroboot, below. Everything else on this page is a good tool for a nearby job.

What one call costs, tool by tool

The chart shows roughly what one call to a small Python function costs with each tool. The scale is logarithmic: each mark to the right is ten times slower than the one before, so a bar twice as long is not twice as slow but many times slower. A solid bar (█) is the usual cost; a light part (▒) is the range above it. Every bar except the first includes starting Python itself; the first does not, because the warm zygote started Python long before the request.

                          1 ms          10 ms         100 ms        1 s
                          │·············│·············│·············│
Zygo exec (warm fork)     ██                                           1.4 ms
Zygo run, nsjail, kern    ███████████████▒▒▒▒▒▒                        12–40 ms
gVisor                    █████████████████████████▒▒▒▒▒▒              ~60–160 ms
Firecracker microVM       ██████████████████████████████▒▒▒▒           ~140–250 ms
docker run                ███████████████████████████████████▒▒▒▒▒▒▒   300–1000 ms

The Zygo numbers are measured by this project: zygo exec is the warm path’s median, and zygo run the median of python3 -c pass with the image already pulled (chapter 25). The one-shot row is one cluster on purpose. zygo run is not drawn faster than nsjail or kern, because it is not: inside Windmill, nsjail and Zygo in its place cost the same 19–20 ms per job (measured below), and on the Raspberry Pi kern box and zygo run tied (chapter 25). The light part of that bar is where the same tools land with a heavier script or an older kernel. The rows below it are each project’s own claims, here to show the order of size. The lesson is in the shape: the one-shot tools cluster together, because they all pay for building a sandbox and starting Python, and only a warm fork leaves that cluster.

The request path

This table compares what each tool costs on every request, and what it costs once, up front. The Zygo columns are measured; the other columns are the projects’ documented or commonly measured figures.

How to read the Zygo numbers: we timed many requests. “Usually” is what a normal request costs. “1 in 100” is what the slowest requests cost — only one request in a hundred was slower than that.

  fast ◀──────────────── 100 requests, sorted ────────────────▶ slow
        · · · · · · · · · · · · ▲ · · · · · · · · · · · · · · ▲ ·
                                usually                  1 in 100

These are the time a tool adds. Your own code’s time comes on top.

Docker runDocker execFirecrackergVisor runscAWS Lambda (warm)zygo runzygo exec --runtime (pool)zygo exec (warm)
Per-request overhead300–1000 ms50–100 ms~125 ms boot; 10–20 ms from a snapshot50–150 ms~1–5 ms + platformusually 12 msusually 1.9 ms · 1 in 100: 11.4 ms¹ · a different script each timeusually 1.4 ms · 1 in 100: 10.5 ms¹
Paid once, up front—a docker run -dthe VM’s own boot, or a snapshot—a cold start, platform-side—a zygo serve --runtime: the interpreter and its dependencies, once for every scripta zygo serve: ~150 ms for a Python handler, plus its imports
Clean state per requestyesnoyesyesnoyesyes (a fresh process; the script is loaded in it)²yes (a fresh process)²
Daemonyesyesyes (a VMM per VM)yes (runsc + shim)n/anono system service: a supervisor under your userno system service: a supervisor under your user
Rootdaemon runs as rootsameneeds /dev/kvmno, in rootless mode (how Zygo runs it); then its cgroup limits are advisoryn/anonono
Wallkernelkernelhardwareuserspace kernelhardwarekernel (ns); userspace kernel (gvisor); hardware (vm)kernel (ns only)kernel (ns only)

¹ On Linux 6.x, 1 request in 100 waits about 9 ms for the kernel to move it into its cgroup; on Linux 5.10 the same is 2.6 ms (warm) and 3.2 ms (pool). Chapter 25 explains it. Zygo numbers are from a Lima VM, Linux 6.8, 25 September 2026.

² Clean as in a fresh process forked from a zygote that has never served a request. A fork still shares what the zygote had before any request: its memory layout, a socket opened at import time, a literal /tmp/... path. Fork safety goes through each.

The first row is the whole argument. A container’s cost is the machinery around it and the cold start of the interpreter. Zygo takes the machinery off the request path entirely, and it pays the interpreter’s start only once, in a warm zygote. That is not a new observation: SOCK (USENIX ATC 2018) and Catalyzer (ASPLOS 2020) made the same one for serverless platforms, with a zygote and a snapshot respectively (chapter 6).

The three Zygo columns are three answers to “what is warm?”. zygo run keeps nothing warm. A pool keeps the interpreter and its dependencies warm, but no code: each request brings its own script, so one pool serves thousands of different scripts, for about 0.5 ms more than a function. A warm function keeps one handler loaded, which is the fastest, but costs one zygote per script (chapter 13).

  what is warm?        zygo run          pool                 warm function
                       ────────          ────                 ─────────────
  sandbox              built each time   ready                ready
  interpreter + deps   started each time ready                ready
  your code            loaded each time  loaded per request   ready
  per request          12 ms             1.9 ms               1.4 ms

docker run and zygo run, side by side

Both accept the same sentence — IMAGE [COMMAND…] — and both use OCI images from the same registries. The likeness ends there. The next sections compare them on who runs the command, what is left behind, the defaults, networking, images, and the flags you already know.

Who runs the command

docker run is a client. The request crosses a unix socket to dockerd, a root daemon. dockerd hands it to containerd, which starts a containerd-shim, which runs runc. runc sets the container up and exits; the shim stays for as long as the container lives. That is five processes, three RPC boundaries (calls from one program to another), all of them root. Nearly all of the 300–1000 ms is that chain; the isolation itself — the namespaces, the cgroup, the seccomp filter — is about a millisecond of it. Chapter 5 walks the chain step by step.

zygo run is one process, with no RPC and no root. zygo itself calls clone3 into a fresh set of namespaces. The child applies the mount plan, calls pivot_root, joins its cgroup, drops every capability, installs seccomp and Landlock, and calls execve. With the image cached, that takes a median of 12 ms. ps shows zygo with your program directly under it, and nothing in between.

  what ps shows for docker run                what ps shows for zygo run
  ────────────────────────────                ──────────────────────────
  docker (client, your shell)                 zygo (your user)
  dockerd            (root)                     └─ your program
  containerd         (root)
  containerd-shim    (root)
    └─ your program
  (runc started it and has already exited)

What is left behind

docker run creates an object. When the process exits, the container stays (docker ps -a), and its writable layer stays on disk. Something has to docker rm it, or you must have passed --rm. Logs pile up in the daemon, there are restart policies, and docker exec gets you back inside.

zygo run is a process. When it exits, the namespaces, the cgroup and the tmpfs go with it; there is nothing to remove, and there is no zygo rm. Standard input, output and the exit code pass straight through. The one addition: a sandbox that Zygo or the kernel killed exits with code 137, and --outcome file.json says which it was, with timed_out, oom_killed, peak_rss_kb and wall_ms. --dry-run --json prints the mount plan and the cgroup values without running anything; Docker has nothing like it.

The container you keep around and step into is a separate idea in Zygo: zygo serve and zygo exec, a warm function. It looks like docker exec, except that every request is a fresh process and costs about 1.4 ms.

  docker run IMAGE                          zygo run IMAGE
  after exit:                               after exit:
  ┌──────────────────────────────────┐      ┌──────────────────────────────────┐
  │ container record (docker ps -a)  │      │ nothing                          │
  │ writable layer on disk           │      │ exit code passed through         │
  │ logs in the daemon               │      │ 137 if killed; --outcome says    │
  │ → needs docker rm (or --rm)      │      │ timed_out / oom_killed           │
  └──────────────────────────────────┘      └──────────────────────────────────┘

The defaults

Docker’s defaults are made for software you chose; Zygo’s are made for code you did not. This is what you get with no flags at all.

docker run IMAGEzygo run IMAGE
Root filesystemwritable (a copy-on-write upper layer)read-only; the only writable place is /tmp, a tmpfs sized by scratch (the smaller of 64M and half of mem, so 64M by default)
Capabilities14 kept (NET_RAW, SYS_CHROOT, MKNOD, …)none
seccompa denylist-shaped profile allowing ~350 syscallsan allowlist of ~215 names (the default profile; 190 of them exist on aarch64, all on x86_64); bpf, io_uring, userfaultfd, ptrace, mount, unshare absent; clone refused with any namespace flag
Landlocknoyes, where the kernel has it
Runs asroot in the container, root on the host (outside rootless mode)the image’s uid 1000, mapped to your uid
Networkbridge; everything reachablenone: loopback only
Memory, CPU, pids, wall clockunlimitedall mandatory: mem 256M, cpu 1.0, pids 64, timeout 30s, nofile 1024
Bind mounts-v is rw unless :ro--mount is ro unless :rw
cgroupfs insidemounted read-onlynot mounted at all, so release_agent is not reachable
Escape suite—21 escape vectors attempted on every change, 0 escaping

A denylist names what is forbidden and allows the rest; an allowlist names what is allowed and forbids the rest. release_agent is an old cgroup file that has been used to escape containers, so Zygo does not show the cgroup file system inside at all.

Adding locks versus loosening them

In Docker you add safety: --cap-drop ALL --read-only --security-opt no-new-privileges --memory --pids-limit --network none. In Zygo you loosen it, and every flag that does so says it in its name: --allow-unlimited, --allow-host-net, --allow-private-net. There is no way to turn a limit off without one of them.

This is not “Zygo is safer than Docker”. Docker’s defaults suit software you chose; Zygo’s suit code you did not. The point is that each set of defaults leans the way its use case needs. And every row in the Zygo column is something a test attempts, not a setting a test reads.

  Docker:  open ──(--cap-drop ALL, --read-only, --memory, --network none, …)──▶ tight
  Zygo:    tight ──(--allow-unlimited, --allow-host-net, --allow-private-net)──▶ looser

Networking: four modes, none of them a server

Docker’s network is built for a container that is a service: a bridge with NAT (address translation, so many containers share the host’s address), -p 8080:80 to publish a port, containers that find each other by name, and --network host when you want none of it.

Zygo has four modes, and none of them accepts a connection. none is the default. egress is an allowlist by name — allow = ["api.example.com:443", "*.cdn.example.com:443"] — enforced by nftables inside the sandbox’s own namespace, with a DNS resolver of Zygo’s own. A name off the list does not resolve; a name on it has its addresses added to the filter before the answer goes back. full is the public internet. host means no network namespace and needs --allow-host-net.

Private address ranges and 169.254.169.254 (the cloud metadata address, which often hands out credentials) stay refused in every namespaced mode. If pasta or nft is missing, a networked sandbox does not start, instead of starting without its filter. There is no port publishing; Zygo does not run services. Chapter 14 covers the modes in use.

  mode      reaches                                          needs
  ────      ───────                                          ─────
  none      loopback only                                    (default)
  egress    only the host:port names on the allow list       pasta + nft
  full      the public internet; never private or metadata   pasta + nft
  host      everything the host reaches (no namespace)       --allow-host-net

What the sandbox can reach

The same probe, on the same host, was run through Zygo’s --net full and Docker’s --network bridge: a small script that tries to open a connection to each target and reports what the kernel answers.

targetZygo --net fullDocker --network bridge
cloud metadata 169.254.169.254:80no routerouted (refused by the host, not blocked)
the host’s own Postgresno routereached
private 192.168.1.1:80 (the LAN router)no routereached
private 10.0.0.1:80no routeno route
the sandbox’s default gatewayno routeno route
public internet 1.1.1.1:53reachedreached
                      ┌─────────────────────┐
  Docker bridge ─────▶│ host's Postgres     │◀──── reached
                 ────▶│ LAN router          │◀──── reached
                 ────▶│ metadata address    │◀──── routed (refused by host)
                      └─────────────────────┘
  Zygo --net full ──▶ public internet only; private and metadata: no route

Closing the gap in Docker: four rules

Docker needs four firewall rules to close what Zygo closes by default. They drop traffic from containers to the metadata range and the three private ranges.

iptables -I DOCKER-USER -d 169.254.0.0/16 -j DROP
iptables -I DOCKER-USER -d 10.0.0.0/8     -j DROP
iptables -I DOCKER-USER -d 172.16.0.0/12  -j DROP
iptables -I DOCKER-USER -d 192.168.0.0/16 -j DROP

In a product where the code inside the sandbox is written by a tenant (a customer of the product), this is not a matter of taste.

The egress allowlist, end to end

The same host, the same probes, four configurations: a normal HTTPS request, and a raw socket to a public DNS server.

configurationhttps://example.comraw socket to 1.1.1.1:53
--net noneDNS failsPermissionError
--net egress, no --allowDNS failsPermissionError
--net egress --allow example.com:443200OSError
--net full200reached

One named host opens and nothing else does, with no proxy process anywhere.

Images and dependencies

Both pull the same images from the same registries, and zygo run pulls on first use exactly as docker run does. The stores differ. Docker’s is /var/lib/docker and belongs to root. Zygo’s is under ~/.local/share/zygo, content-addressed (each file is named by a hash of its contents), with the layers unpacked. It uses rootless overlayfs on kernel 5.11 and newer, and a flattened copy below that. zygo login stores a password for a private registry, and an existing ~/.docker/config.json is read too.

The real difference is the Dockerfile. In Docker, adding a dependency means building an image. In Zygo the image is never touched: --requirements ./requirements.txt builds a venv inside a sandbox with the image’s own pip and mounts it at /venv, and system = ["libwebp7"] installs apt packages into a derived OCI layer. Both are keyed on the image digest and the list, built once, and shared by everything that names the same thing. Chapter 15 has the details.

  Docker                                   Zygo
  Dockerfile ─▶ docker build ─▶ new image   image (untouched)
                                              ├─ --requirements ─▶ venv at /venv
                                              └─ system = [...] ─▶ derived layer
                                            built once per (image digest, list)

The flags you already know

DockerZygoNote
-v ./x:/x--mount ./x:/x:rwread-only is Zygo’s default; a single file can be mounted too
-e K=V--env K=Vsecrets are not environment: they arrive as /run/secrets/<NAME>
-w /dir--workdir /dirdefault /app, falling back to /
-u 1000--user 1000
-it--tty
--memory 256m --cpus 0.5 --pids-limit 64--mem 256M --cpu 0.5 --pids 64present in Zygo whether you pass them or not (the default cpu is 1.0)
timeout 30 docker run …--timeout 30sthe whole process tree is killed, through the cgroup
--network none(the default)--net egress --allow host:port for an allowlist
--network bridge--net fullbridge is accepted as a spelling of full; see what the sandbox can reach for the difference
--network host--net host --allow-host-netthe same removal of the wall, and it says so in its name
--security-opt seccomp=…--seccomp default|strict|permissivethree shipped profiles; see chapter 24
--runtime runsc--isolation gvisorsame spec, same command; vm is another value of the same flag
--rm(always)
-d, -p, --restart—Zygo does not run services

Docker

Docker is a general-purpose system for packaging and running services. Zygo uses its images and none of its runtime. If you need docker build, docker compose, long-running services, port publishing, networks between containers or restart policies, you need Docker. Chapter 5 explains how it works.

nsjail

nsjail, from Google, is the closest older relative of Zygo’s one-shot half. It puts a process into namespaces, cgroups, rlimits and a seccomp filter, which you write in a small policy language called Kafel. It can run a command once, run it again and again, or listen on a TCP port and start a fresh jail for every connection, which made it the standard tool for hosting CTF challenges. Windmill and other job runners can wrap each job in it. It has no images — you give it a folder or bind-mount the host’s — and no warm path: every run starts the program from nothing. Choose it when you want a battle-tested, very configurable jail around a command and you manage the root file system yourself. Zygo in nsjail’s place inside Windmill’s workers cost the same per job; the measurement is further down.

bubblewrap

bubblewrap (bwrap) is the small sandbox tool under Flatpak. It creates namespaces, builds a root from the bind mounts you list, and then runs a command — nothing more, on purpose. It has no cgroup limits and no seccomp policy of its own; you pass a compiled filter if you want one. That smallness is its strength: it is easy to audit and it runs everywhere, which is why many desktop and developer tools use it as a building block. Zygo covers what bubblewrap leaves to the caller: images, limits, the filter, the network allowlist, and the warm path.

firejail

firejail sandboxes desktop programs — a browser, a PDF reader — using ready profiles for hundreds of applications. It is installed setuid root, so any user can start a sandbox, but its own code runs with root’s power, and bugs in it have mattered in the past. It is made for confining the apps on your desktop, not for running server-side functions at high rates. Zygo needs no setuid program at all, because user namespaces give it what it needs.

minijail

minijail is Google’s sandbox library and tool for ChromeOS and Android system services. It applies namespaces, capabilities, seccomp and user changes to a service before it starts, from a policy file. It is part of the operating system’s own plumbing and is written for that setting: known services, written by the same team. Zygo is aimed at the reverse: code written by someone else, arriving at request time.

systemd-nspawn and systemd-run

systemd-nspawn starts a whole operating-system tree in namespaces — “chroot on steroids”, in its own words — and is good for booting a distribution in a container for testing. systemd-run can put any command in a transient unit with limits and many sandbox options. Both mostly need root, and both think in units and machines rather than requests. Zygo uses systemd only where it has to: to get a delegated cgroup under your login.

The three you are really choosing between

Docker, Firecracker, gVisor and Lambda are the landmarks; they are not the shortlist. Someone looking for “run this agent’s code somewhere safe” ends up comparing Zygo with three much closer projects: kern, nono and microsandbox. In two of the three cases the honest answer is that they solve a different problem. Two more, Sandlock and Zeroboot, fork a warm process as Zygo does and have a section of their own below. Their claims below are theirs, not measured here, and every one of them moves quickly.

kernnonomicrosandboxZygo
Shaperootless container runtime, one static binarya confinement you apply to a process you already havemicroVM runtime and platformrootless sandbox runtime plus a warm-process protocol
Wallhost kernel (namespaces, seccomp, cgroups)host kernel (Landlock + seccomp; Seatbelt on macOS)hardware (libkrun)host kernel (ns), userspace kernel (gvisor), hardware (vm)
OCI imagesyesno images at allyesyes
Per-call costa fresh box, single-digit msnone — it confines a process you were starting anywaya microVM, boot under ~100 msa fresh sandbox, 12 ms — or a fork into a warm one, 1.4 ms
State between callsnone: the box is destroyedwhatever your process kepta sandbox can be kept, branched and snapshottednone, and not by destroying anything: each request is a fork() of a process that has never served one
Runs on macOSLinux and WSL2yes, natively, with Seatbeltyesthrough a Linux VM it manages
Daemonnononono system service; warm functions live under a supervisor that runs as your user

kern

kern is the closest match to Zygo’s one-shot half today: rootless, daemonless, one static Rust binary, OCI images, namespaces with a seccomp allowlist and cgroup v2, a box made and thrown away per call in single-digit milliseconds by its own figures. If you want docker run without the daemon and without the 300 ms, both projects answer the same question, and kern’s answer is a good one. Measured side by side on a real Python workload, the two are within 3–5% of each other; the numbers are further down.

They part ways on what happens next. kern makes the box cheap enough to throw away every time, so there is nothing to keep warm. Zygo notes that the expensive part is not the box but the interpreter inside it: a Python process with its imports done is 150 ms or more that a per-call box pays again on every call, whatever the box costs. zygo serve pays it once, and zygo exec forks into it for 1.4 ms, with request n running on a copy of the memory the zygote had before request n−1 existed. That warm path, the protocol behind it, and the per-request cgroup, deadline and secrets that hang off it are Zygo’s real subject. The one-shot runner is the part that had to exist underneath it.

kern also has something Zygo lacks: virtual resource slices (vcpu:, vdisk:, vgpio:) declared in a config file and attachable to a bare host process. Zygo has no equivalent and no plans for one.

  kern, per call                         Zygo exec, per call
  ┌──────────────────────────────┐       ┌──────────────────────────────┐
  │ make box (cheap)             │       │ fork warm zygote     ~1.4 ms │
  │ Python start+imports ~150 ms │       │ (Python + imports paid once) │
  │ run, then destroy box        │       │ run, then child exits        │
  └──────────────────────────────┘       └──────────────────────────────┘

nono

nono is not so much a rival as a different layer, and the words overlap, so it is worth saying so. nono applies Landlock and seccomp — Seatbelt on macOS — to a process you are starting anyway: your coding agent, running as you, with your files. There is no image, no namespace, no cgroup and no runtime. What it gives you is that the agent cannot read ~/.ssh or reach a host you did not allow, enforced by the kernel and impossible to undo once applied. It works natively on a Mac.

That is the right tool for confining an agent that is meant to edit your working folder. It is the wrong one for running code the agent wrote, because that code still runs as you, in your files, with your environment — a narrower version of you, but still you. Zygo is the other half: the agent stays outside, and the code it generates goes into a sandbox with its own root file system, its own pid namespace, a memory limit and a deadline. The two fit together, and on a developer’s machine using both is reasonable.

  ┌─────────── nono: confines the agent, running as you ───────────┐
  │  coding agent (can edit your folder, cannot read ~/.ssh)       │
  │      │                                                         │
  │      └─ generated code ─▶ zygo run / zygo exec                 │
  └──────────────────────────────┬─────────────────────────────────┘
                                 ▼
             ┌─────── Zygo sandbox: own root, own pids ─────────┐
             │ memory limit · deadline · cannot see your files  │
             └──────────────────────────────────────────────────┘

microsandbox

microsandbox is the closest project to Zygo’s vm backend, and further along that road. It is microVM-first: every sandbox is a libkrun guest with its own kernel — libkrun is the same library behind Zygo’s vm backend. It supports OCI images, claims a boot under 100 ms, and can snapshot and branch a live sandbox, which Zygo cannot do at all. It ships Python, TypeScript and Rust SDKs and an MCP server, as Zygo does.

The difference is where the default sits. microsandbox’s wall is hardware for everything. Zygo’s default is the host kernel, with hardware available as --isolation vm for the work that needs it — the same spec and the same command. That is a real trade, and it does not go one way. A microVM per request is a wall a kernel bug does not cross, and Zygo’s ns backend is one kernel away from the host, as chapter 23 says in as many words. What Zygo has instead is the warm path — 1.4 ms, a fork, clean state — which a VM per request cannot reach, and which is the only reason the project exists.

If your code is truly hostile and 100 ms per call is affordable, microsandbox’s default is the safer one. If you run a thousand short calls a minute from your own users’ scripts, Zygo’s is the faster one, and --isolation vm is there for the part that is not safe to run on ns. Zygo’s vm backend is also, today, much less than microsandbox: it boots a guest and runs one-shot sandboxes, and warm functions and networking inside the guest are not built; ADR 0002 says why.

Sandlock and Zeroboot: two other forks

Two projects from 2026 fork a warm process for each call, as zygo exec does. Each does it from a different row of the map. Their numbers below are their own claims, from their pages, not measured here.

Sandlock (Multikernel) confines a process with Landlock and seccomp — no namespaces, no cgroups, no image. Its template mode starts a Python process once, lets it run its init(), then forks it for each call: about 0.7 ms a clone by its own figures, with the interpreter’s state and imported modules shared copy-on-write. It is a library with Rust, Python and Go bindings, a CLI, and a shim that lets it stand in as an OCI runtime; its README names Linux 6.12 for the current release. On the map it is the bottom row: the clone runs as you, in your filesystem narrowed by Landlock, with no root of its own, no pid namespace and no cgroup. Zygo’s fork lands in a sandbox with its own root, its own pid namespace, a cgroup and a deadline per request, and the supervisor, tenants, secrets and HTTP API around it.

Zeroboot snapshots a Firecracker microVM with the runtime loaded and maps the snapshot’s memory copy-on-write for every new VM: 0.79 ms usually and 1.74 ms for 1 in 100 by its own figures, each fork a VM with a kernel of its own. A fork has no network — serial I/O only — and one vCPU; it needs KVM, and the project calls itself a working prototype that is not production- hardened. It is the top row’s answer to the same question, and the one to watch for T3 code, where Zygo has only a one-shot vm today.

  Sandlock clone                  Zygo exec                       Zeroboot fork
  ──────────────                  ─────────                       ─────────────
  fork a confined process         fork a sandboxed zygote         fork a VM snapshot
  Landlock + seccomp              namespaces, cgroup, seccomp,    KVM, own kernel
  your files, narrowed            Landlock; own root, own pids    no network, 1 vCPU
  ~0.7 ms (its claim)             1.4 ms (measured, chapter 25)   0.8 ms (its claim)

gVisor

gVisor is a kernel written in Go that runs in user space. Your program’s syscalls go to it, not to the host, and it answers most of them itself, using only a small, filtered set of host syscalls. That gives a much smaller attack surface than the host kernel, without needing KVM or a virtual machine. The cost is speed on syscall-heavy work, and some programs that need rare kernel features. Its runtime, runsc, is an OCI runtime. Zygo’s gvisor backend uses it for one-shot runs: zygo backend install gvisor, then zygo run --isolation gvisor. Warm functions stay an ns feature, for the reason given in chapter 8.

Firecracker, Cloud Hypervisor, and platforms on them

Firecracker is the microVM monitor behind AWS Lambda and Fargate: a tiny virtual machine per workload, with a real kernel of its own, booting in about 125 ms or restoring from a memory snapshot faster still. Cloud Hypervisor is a similar microVM monitor. They give the strongest wall on this page, at a boot cost per VM, and they need KVM, so they rarely run inside another cloud VM. Platforms such as E2B build hosted sandboxes for AI agents on top of Firecracker. Its snapshot restore is the one thing here that resembles Zygo’s fork, one level down: a whole VM restored instead of a process copied.

Zygo’s vm backend is built on libkrun for the same purpose: anonymous code, not your own. It boots a guest and runs one-shot sandboxes, with a private writable layer over the image. Warm functions and guest networking are refused on it by decision, not by omission; ADR 0002 explains why.

Kata Containers

Kata Containers runs each container, or each Kubernetes pod, inside a lightweight virtual machine, while still looking like a normal container to Kubernetes. It gives you a hardware wall without changing how you deploy. The price is a VM’s start-up time and memory for every pod. It is built for long-lived services on a cluster, where Zygo is built for short calls on one machine.

AWS Lambda and its relatives

Lambda and similar services are managed platforms. Zygo is a local runtime with a similar shape — a function, a warm instance, a request — and no platform around it: no billing, no scaling across machines, and no ingress (accepting connections from outside).

Hosted sandboxes for agents: E2B, Modal, Daytona and the rest

Since 2025 a new group of products sells a sandbox for an AI agent: E2B, Modal Sandboxes, Daytona, Vercel Sandbox, Cloudflare Sandboxes, Blaxel, Docker’s own Sandboxes, and more every quarter. The README names three of them; this section says where they sit on the map above, because the word “sandbox” covers two different things.

What they are. Each gives an agent a session: a machine of its own with a filesystem, a shell, packages it can install, ports it can expose, and a lifetime of minutes to hours, billed by the second. The wall is a microVM (Firecracker at E2B and Vercel, a custom monitor at Docker) or gVisor (Modal), and the session can often be paused, snapshotted and resumed. They run in the vendor’s cloud; some can be self-hosted, at the cost of running their control plane. Everything in this paragraph is their own description, not measured here.

Where they sit. On the map they are the top-left cell — a virtual machine, built for each session — with one addition: the session stays. That is the right shape for an agent that writes code, runs it, reads the error and tries again for half an hour. It is the wrong shape for what Zygo is for: a function that runs for milliseconds, thousands of times, and must start clean each time. A session per call would cost a VM boot, or a snapshot restore, per call.

  a hosted agent sandbox                 a Zygo warm function
  ──────────────────────                 ────────────────────
  one session, minutes to hours          one call, milliseconds
  state kept between commands            no state between calls
  a VM (or gVisor) per session           a fork per call, on one kernel
  in their cloud, billed per second      on your machine, no billing
  pause, snapshot, resume                nothing to resume: warm again
  ports, a shell, a desktop              no ingress, no shell in the request

Choosing. If an agent needs a machine to work in — install packages, run a server, keep files between steps — use one of these, or microsandbox on your own hardware. If a program needs to run many small pieces of untrusted code — an agent’s tool calls, a customer’s plugin, a workflow step — and each must be cheap and clean, that is Zygo. The two combine: an agent living in a hosted session can still call a Zygo function for the tool that must answer in a millisecond.

runc, crun and youki

These are the low-level OCI runtimes: given a folder and a config.json, they do the list at the end of chapter 4 and exec the program. runc is written in Go, crun in C, youki in Rust. They are the part of Docker and Podman closest to zygo run, and on their own they are fast. But they expect someone else to prepare the bundle, pull images, keep records and clean up — which is the chain from chapter 5. Zygo does its own launching, so it needs none of them.

Workflow engines: Windmill and friends

Windmill, Temporal and similar workflow engines run users’ scripts, and they need a sandbox for each run. Today that is often nsjail or a container per job. These engines are exactly the embedder Zygo is designed for — the program that builds Zygo into itself. A worker calls zygo serve once per script, or uses the SDK, and each script run becomes a fork() instead of a container. examples/workflow-engine/ is such a worker.

A prefork pool of your own

The first question an embedder asks is a fair one: why not fork the warm interpreter yourself? Python can. A prefork pool loads the code once and forks workers from it. Python’s multiprocessing has a forkserver start method that imports the modules you name once and forks a child from them for each task. gunicorn --preload --max-requests 1 loads the application in its master and replaces each worker after one request. Either gives the speed trick zygo exec uses: the imports are paid once, and each request runs in a fresh copy.

What neither gives is a wall. The child runs as your user, in your file system, on your network, with no memory, process or time limit of its own unless you add one. For code your own team wrote, that is enough, and simpler than Zygo: use it. For code a customer or an agent wrote, the fork is the easy half. The hard half is what Zygo puts around the copy before it runs, which chapter 6 walks through. Node cannot fork a running process at all, so there a prefork pool is a pool of pre-loaded workers, as Zygo’s Node agent keeps (chapter 13).

  your own prefork pool                  zygo exec
  ─────────────────────                  ─────────
  fork the warm interpreter              fork the warm interpreter
  the child runs as you                  own user, pid, mount and network namespaces
  your files, your network               its own root; network off unless allowed
  limits: whatever you add               a cgroup and a deadline per request
  no syscall filter                      seccomp allowlist, Landlock
  right for your own code                right for code others wrote

WebAssembly runtimes

Wasmtime, Wasmer, Spin and Extism run code compiled to WebAssembly (Wasm): a portable instruction format that runs inside the runtime’s own process. A Wasm module can touch only the memory it was given and the host functions it was handed, so the wall has no kernel in it at all. A new instance starts in well under a millisecond, by their own figures. Tools such as Wizer can even run a module’s start-up once and save the memory it leaves, which is the Wasm form of a zygote.

The price is the compile step. Your code, and every library it imports, must be built for Wasm. Pure Python and JavaScript run on interpreters compiled to Wasm, but a package with native parts, such as numpy or a database driver, needs a Wasm build of its own. Some projects, Pyodide for one, ship builds of popular packages; most of PyPI and npm has none. A module sees files and the network only as far as the host opens them to it. Zygo makes the opposite trade: any program in any OCI image runs unchanged, and the wall is the host kernel, with what that costs (chapter 23). If your plugins are small, self-contained and built with a toolchain you control, Wasm is the stronger boundary. If they install whatever they import, that is Zygo’s column.

Measured: Zygo against nsjail and kern

nsjail and kern are the two one-shot runners closest to zygo run, so both were measured against it under load, on 24 September 2026. Every run happened in the same VM: Ubuntu 24.04, kernel 6.8, aarch64, 2 vCPU, 4 GB, on an M1 Max. Every binary ran from the VM’s own disk. The load generator ran outside the VM, so its CPU counts for nobody. “CPU per job” is the CPU of the whole stack being measured, divided by the jobs it finished. Unlike the numbers in chapter 25, these tables cannot be repeated from this repository: the load generator and the raw results were not kept (what these numbers are not).

Inside Windmill, in place of nsjail

Windmill CE v1.817 with three general workers ran a trivial Python script and a CPU-bound one (about 20 ms of Python) through nsjail, using Windmill’s own nsjail config. Then the same workers ran them through Zygo in nsjail’s place, and nothing else changed. Zygo ran two ways:

  • Zygo as nsjail: the zygo binary, installed under the name nsjail, read nsjail’s command line and config itself. This translation was built for the benchmark only and is not in Zygo today.
  • Zygo through a script: a shell stand-in translated the config and called zygo run. This is what works with Zygo as it ships.

Per job, 40 runs in a row inside a worker:

nsjailZygo as nsjail
wall time19–20 ms20 ms
CPU18.8–19.0 ms18.8–19.3 ms

Under load, the whole Windmill stack. nsjail was measured twice; its ranges cover both runs.

nsjailZygo as nsjailZygo against nsjailZygo through a script
burst of 200, trivial: jobs/s51.450.0−3%43.6
burst of 200, CPU-bound: jobs/s38.0–38.337.0−3%33.4
CPU per job, trivial burst32.8 ms33.2 ms+1%38.1 ms
CPU per job, CPU-bound burst44.7–45.4 ms46.2 ms+2–3%50.7 ms
20/s steady: usually / 1 in 10055–57 / 97–102 ms56 / 94 mslevel62 / 103 ms
40/s steady: usually / 1 in 10057–60 / 91–99 ms65 / 112 ms+8–14% / +13–23%87 / 156 ms
highest rate sustained48.5–49.6/sabout 47/s−4–6%about 41/s
idle memory of the stack384–634 MB534–590 MBlevel531–565 MB
failed jobs00 of 2 6000
per-job cgroup limitsnomemory, processesmemory, processes
seccomp, Landlocknoyes, yesyes, yes

Level per job, and 1–6% behind at saturation, while doing more. Zygo gave every job a cgroup with memory and process limits, a seccomp allowlist and a Landlock ruleset, and Windmill’s nsjail config sets none of those. The likely cost at saturation is the cgroup Zygo creates and removes per job: lru_gen_online_memcg, cgroup_addrm_files and tg_set_cfs_bandwidth show in perf. That was not measured on its own. Below saturation the two cannot be told apart. Through a shell script, the same swap costs about 15% of throughput, because the script’s sh, awk, grep and env add about 4 ms to every job. Idle memory does not move, because neither sandbox stays resident between jobs.

Running it found three defects in Zygo, all fixed:

  • Two runs could share a staging directory. The three workers shared one Zygo store, but each had its own PID namespace, and a staging root was named after the PID. 2 jobs in 200 failed. Every per-process name now carries the PID and 64 random bits.
  • A writable mount of a single file never started. Landlock was given directory rights on a regular file, and the kernel answers that with EINVAL. nsjail configs hand a job its result.json exactly this way. A rule on a file is now narrowed to the file rights.
  • The stand-in script itself cost 4 ms a job, as described above.

Inside n8n, as its Code-node runner

n8n runs a Code node through a task runner, a process apart from n8n. examples/n8n-runner is one that sends each task to a Zygo runtime pool, and was measured against n8n 2.38.7’s own runners behind the same n8n (chapter 25 has the method and every table).

n8n’s runnerZygo’s runner
Python, one request, usually213 ms36 ms
Python, 200 at once, per second7.632.1
JavaScript, one request, usually27 ms49 ms
JavaScript, 200 at once, per second26.523.6
first run after 30 s idle, JS · Python975 · 496 ms56 · 38 ms
Code node with modules allowed reaches n8n, the LAN, the internetyesno
a task over its memory limitno limitdies alone

The two languages go opposite ways for one reason. n8n’s JavaScript runner runs every task in one Node process, which is cheap and shares everything; its Python runner starts a process per task and pays about 200 ms of CPU for it. Zygo pays for a process per task in both, about 5 ms in Python and 25 ms in Node, which cannot be forked. The Zygo runner covers the Code node’s items and both run modes, not n8n’s RPC helpers or binary data.

An embedder’s harness, against kern

The workload was a real embedder’s Python harness: it reads an event, runs a user’s handler, and writes the result through a read-write scratch mount, under the embedder’s limits. /bin/true measured the runtime alone. There were 64 runs at each concurrency from 1 to 32, twice. The ranges cover both passes and every concurrency level. kern bc822de ran with --security-profile untrusted.

python:3.12-slim ships no bytecode. Zygo compiles it once into a layer of its own, automatically (chapter 15). kern does not, so it is shown both as it ships and with an image precompiled by hand.

Zygokern, precompiled imagekern, stock image
the harness
runs/s60.6–66.863.2–68.526.5–28.5
CPU per run29.5–32.7 ms28.4–31.2 ms69.8–75.3 ms
time per call, one at a time (usually)25.9 ms23.4–23.7 ms59.1–59.6 ms
/bin/true
runs/s222–252287–314
CPU per run7.5–9.4 ms5.5–6.6 ms
time per call, one at a time (usually)7.0–7.7 ms4.0–4.2 ms
failures000

On the real workload Zygo is within 3–5% of kern at its best, and more than twice as fast as kern as it ships. On an empty program kern is about 2 ms of CPU per run cheaper. That is the start-up floor: a 7.9 MB binary against 2.2 MB, a larger plan (≈1.5 ms), and Landlock, which kern does not apply by default and Zygo keeps.

Before this comparison, Zygo made 42 harness runs a second at 45 ms of CPU each. The comparison found these, all fixed:

  1. Zygo could not run from a delegated cgroup that also held its caller, a systemd unit with Delegate=yes or a service in a container. It exited 125. zygo.slice now goes to the top of the tree delegated to the user.
  2. Every run re-executed under a transient systemd scope, about 10 ms of CPU, because the delegation check asked about the wrong cgroup.
  3. The host probe ran on every run. It is now cached per boot for up to ten minutes.
  4. Zygo moved its own process into a cgroup on every run, 1.9–6.0 ms of a 6–11 ms start. It now moves only when it is in the way.
  5. The sandbox child was moved into its cgroup after clone3, which waits out an RCU grace period after a quiet spell. It is now born there with CLONE_INTO_CGROUP, the order kern uses, which also puts the limits on from the child’s first instruction. The sandbox start went from 6–11 ms to 3–4 ms.
  6. The exit wait slept with a backoff, so a program that exited at 13 ms was noticed at 25 ms. It now uses pidfd_open and poll.
  7. Every run built a registry client, with TLS roots and a multi-threaded runtime, to read one local file.
  8. A failed bytecode build was retried on every run, at 170–200 ms each. It is now remembered for an hour.

A run now starts 4 processes, down from 13 at the worst.

What these numbers are not

  • They are all cold. Every job started a fresh interpreter. Zygo’s warm path, a fork into a zygote that has already imported everything, was not part of either comparison. Neither nsjail nor kern has one to compare it with.
  • One VM, one kernel. On kernel 5.10, in Docker Desktop’s VM, the same sandbox made Python about twice as slow as a plain container did. That cost belongs to the old kernel, and it would have been measured against every namespace-based runner alike.
  • The raw results and the load generator were not kept. The tables here are their summary, and they cannot be repeated from this repository. The numbers in chapter 25 can: make bench-record writes them as JSON, and bench/ holds the records so far.

Everything in one table

WallImagesLimitsWarm forkRoot neededMain use
Zygohost kernel · gVisor · VMOCIcgroups, mandatory, per requestyesnoshort functions, others’ code
Dockerhost kernelOCIcgroups, opt-innodaemon (or rootless mode)services, packaging
Podmanhost kernelOCIcgroups, opt-innonoservices, without a daemon
nsjailhost kernelno (a folder)cgroups, rlimitsnodepends on featuresCTFs, job runners
bubblewraphost kernelno (bind mounts)nonenono (or setuid)desktop apps, a building block
firejailhost kernelnosomenosetuid rootdesktop apps
minijailhost kernelnosomenousuallyOS services
kernhost kernelOCIcgroupsnonofast throwaway boxes
nonohost kernel (Landlock)nonon/anoconfining an agent
Sandlockhost kernel (Landlock, seccomp)nosome, through seccomp notification (its claim)yes, of a confined processnoforking a warm Python without a container
ZerobootVMa snapshotthe VM’syes, of a VM snapshotKVM accesssub-millisecond VMs for hostile code; a prototype
gVisorsecond kernelOCIcgroupsnono, in rootless mode (how Zygo runs it); then its cgroup limits are advisorysafer containers
microsandboxVMOCIthe VM’sno (snapshots)nohostile code
FirecrackerVMno (a disk image)the VM’sno (snapshots)KVM accessserverless platforms
E2B, Modal, Daytona, Docker SandboxesVM or gVisor, per sessionOCI or their templatesthe VM’sno (snapshots)their cloud (or self-host)an agent’s working machine
KataVMOCIthe VM’snoyessafer Kubernetes pods
FreeBSD jailhost kernelno (a folder)rctl, opt-innoyeslong-lived services on FreeBSD
a prefork pool of your ownnone: your user, your filesnowhatever you addyes, unconfinednoyour own code, quickly
Wasmtime, Wasmer, Spin, Extismthe Wasm runtime; no kernel in the wallno: a Wasm modulememory, and CPU by metering (theirs)a saved start-up (Wizer)nosmall plugins built for Wasm

What Zygo does not do

  • Run on macOS or Windows natively. Sandboxes are Linux; on a Mac, Zygo manages a Linux VM for you.
  • Provide ingress. No mode accepts connections; a function is called through the CLI, the SDKs or Zygo’s own HTTP API.
  • Scale past one machine. Capacity is a per-host budget, and requests past it get HTTP 429 (too many requests).
  • Hide the kernel. The ns backend is one kernel, and every chapter of this book says so.

Choosing, in short

For long-lived services, use Docker, Podman or Kubernetes. For hostile code where 100 ms or more per call is fine, use a VM wall: Firecracker, Kata, microsandbox, or Zygo’s vm backend. For confining a tool you already run, look at nono or bubblewrap. For a quick throwaway sandbox around one command, nsjail, kern and zygo run all do well. For your own code, a prefork pool is enough; for small plugins built for Wasm, a Wasm runtime is the stronger wall. For many short calls to code other people wrote, where each call must start clean and costs must stay in milliseconds, that is the right-hand column of the map, and that is Zygo.

In one sentence: docker run asks a root daemon to create a container object from an image; zygo run runs a program as a locked-down process under your own user — the same kernel parts, the opposite defaults, no chain in between, and nothing left behind.

11. Getting started

This chapter takes you from nothing to three warm functions behind an HTTP API. You install Zygo, ask it whether your machine can run sandboxes, run one program in a sandbox, and then keep a function warm and call it. Each step is short, and each one links to the chapter that explains it in full.

The road through this chapter

  install ──▶ zygo doctor ──▶ zygo run ──▶ zygo serve ──▶ sandbox.toml ──▶ zygo api
  (a file)    (can this       (one        (one warm      (three          (call them
               host do it?)    sandbox)    function)      functions)       over HTTP)

You can stop after any step. A CI job that only needs zygo run never has to learn about warm functions, and a Mac user can do all of it without setting up Linux by hand.

The five-minute version

If you would rather type first and read later, this is the whole road in one block. Every line is explained further down the page.

# install: Linux, x86_64 or aarch64 (a Mac: brew install mhmtskrc2/zygo/zygo)
url=https://github.com/mhmtskrc2/zygo/releases/latest/download
curl -fsSLO "$url/zygo-$(uname -m)-unknown-linux-musl.tar.gz"
curl -fsSL "$url/SHA256SUMS" | sha256sum -c --ignore-missing
tar xzf zygo-*-unknown-linux-musl.tar.gz && sudo install -m 0755 zygo-*/zygo /usr/local/bin/

zygo doctor                                   # can this host run sandboxes? --fix if not
zygo run python:3.12-slim python3 -c 'print("hello")'   # one sandbox, thrown away

echo 'def handler(event): return {"got": event}' > handler.py
zygo serve ./handler.py --name echo           # warm it once: about 150 ms
zygo exec echo '{"n": 1}'                     # a fresh fork of it: about 1.4 ms
zygo stop echo

If zygo doctor reports something red, its section below says what each line means, and zygo doctor --fix applies the usual fixes for you.

What you need

Zygo is one static binary: a single file that carries everything it needs, with no libraries or runtimes to install beside it. It builds sandboxes out of Linux kernel features, so the sandboxes themselves always run on Linux.

Your machineWhat happens
Linux, kernel 5.3 or newerZygo runs directly. Nothing needs root.
macOSZygo starts a small Linux virtual machine for you and runs inside it.
WindowsZygo runs inside WSL2, Windows’ Linux VM; see below.
A dev container or a CodespaceThe repository’s .devcontainer sets one up with sandboxes working; see below.
Anything elseRun Zygo in a Linux VM or container; either is fine.

On Linux you also need unprivileged user namespaces (a normal user may make the private views from chapter 2) and cgroup v2 delegation (a normal user may own part of the cgroup tree from chapter 3). Most modern distributions have the first; the second often needs one small fix, which zygo doctor can apply for you.

Installing on Linux

Release builds exist for x86_64 and aarch64 (64-bit ARM, such as a Raspberry Pi 5 or a Graviton server). Both are static musl builds: musl is a small C library that is linked into the binary, so it does not depend on the host’s own libraries.

url=https://github.com/mhmtskrc2/zygo/releases/latest/download
curl -fsSLO "$url/zygo-$(uname -m)-unknown-linux-musl.tar.gz"
curl -fsSL "$url/SHA256SUMS" | sha256sum -c --ignore-missing   # prints "OK"
tar xzf zygo-*-unknown-linux-musl.tar.gz
sudo install -m 0755 zygo-*/zygo /usr/local/bin/zygo

uname -m prints x86_64 or aarch64, which picks the right file. The second line checks the archive against the SHA256SUMS file that every release carries, so you know it is the one that was published. The sudo is only for copying the file into /usr/local/bin; Zygo itself never runs as root.

SHA256SUMS is itself signed, in every release after 0.1.1. With cosign installed, this proves the checksums came from this project’s release workflow, not only from the same download page:

curl -fsSLO "$url/SHA256SUMS"
curl -fsSLO "$url/SHA256SUMS.sigstore.json"
cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json \
  --certificate-identity-regexp '^https://github\.com/.*/\.github/workflows/release\.yml@refs/tags/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Each release also carries an SBOM — a list of every library compiled into the binary, with its version — as zygo-<version>.cdx.json, in the CycloneDX format that security scanners read.

Installing on macOS

On a Mac, Homebrew installs three things: a small zygo shim for macOS (a thin program that passes your commands on), the Linux build of Zygo that the shim forwards into, and Lima, the tool that starts the Linux VM.

brew install mhmtskrc2/zygo/zygo

The formula lives in a tap (a small GitHub repository of Homebrew formulas), mhmtskrc2/homebrew-zygo; Homebrew finds it from the name. The release build writes the formula there, with checksums computed from the same archives, not typed in by hand. The section How Zygo runs on a Mac below explains what the VM is and what it costs.

Other ways to install

# from crates.io, anywhere with a Rust toolchain
cargo install zygo-cli

# from a checkout of the repository
cargo build --release
sudo install -m 0755 target/release/zygo /usr/local/bin/zygo

cargo install zygo-cli builds the default set of features. The vm backend is not in it. That backend links a virtual machine monitor from a pinned git tag, and a crate published on crates.io may not point at a git tag. So make vm-build from a checkout is the way to get it, and zygo doctor tells you this on a host where vm would otherwise work. Chapter 16 explains the backends.

The container image

Zygo also ships as a container image, ghcr.io/mhmtskrc2/zygo. It is Alpine Linux plus the static zygo binary and the few helpers networking needs. It does not need --privileged, but it does need a few specific things from Docker, because Zygo builds sandboxes inside it:

docker run --user 0:0 --security-opt seccomp=unconfined \
    --security-opt systempaths=unconfined --security-opt apparmor=unconfined \
    --cgroupns=host --cgroup-parent=/zygo -v /sys/fs/cgroup/zygo:/sys/fs/cgroup/zygo:rw \
    -p 7700:7700 -e ZYGO_API_TOKEN=... ghcr.io/mhmtskrc2/zygo
OptionWhy Zygo needs it
seccomp=unconfinedDocker’s own seccomp filter refuses to create a user namespace.
systempaths=unconfinedDocker hides parts of /proc, and then the kernel refuses a fresh /proc inside a user namespace.
--cgroupns=host --cgroup-parent=/zygo and the /sys/fs/cgroup/zygo mountA sandbox needs a cgroup to live in. This gives the container one subtree of its own, not the whole host tree.
--user 0:0The cgroup folder belongs to root. No sandbox runs as that root: each has a user namespace of its own.
apparmor=unconfinedOnly on a host with AppArmor, such as Ubuntu, whose default Docker profile refuses the mounts a sandbox makes. Harmless elsewhere.

Sandboxes with a network need --device /dev/net/tun as well, and a volume keeps pulled images across restarts; chapter 16 has the full command. zygo doctor names anything that is missing, inside the container just as on a host. packaging/oci/ has the details, and chapter 16 covers running it in production.

On Windows, through WSL2

WSL2 is a real Linux kernel in a small VM that Windows manages, so Zygo runs there as on any Linux host. The project has not tested it yet — no CI runner or development machine here is Windows — so treat this as the route that should work, and tell us if it does not. Three settings matter:

  1. A distribution with systemd. In Ubuntu 24.04 under WSL2, put this in /etc/wsl.conf, then run wsl --shutdown from Windows:

    [boot]
    systemd=true
    

    systemd is what gives your user a delegated cgroup, as on a desktop Linux.

  2. cgroup v2 only. WSL2 can mount the old cgroup v1 hierarchy beside v2, and Zygo needs v2 on its own. In %UserProfile%\.wslconfig on the Windows side, then wsl --shutdown again:

    [wsl2]
    kernelCommandLine = cgroup_no_v1=all
    
  3. Install as on Linux, with the commands above, then run zygo doctor. It checks the kernel version, user namespaces and cgroup delegation there, and zygo doctor --fix applies what it can.

WSL2’s kernel is 5.15 or newer, well above Zygo’s 5.3 floor; below 6.1 doctor calls it degraded, as on any host, and it reports whether the kernel was built with Landlock. There is no /dev/kvm by default, so the vm backend is off. Troubleshooting covers what doctor reports.

In a dev container or a Codespace

The repository has a .devcontainer for VS Code’s Reopen in Container and for GitHub Codespaces. It builds Zygo, installs the tools the tests and networked sandboxes need, and runs zygo doctor. Sandboxes work inside it: one-shot runs, warm functions and egress networking were checked in it.

It needs two things a default container does not give. The container is started --privileged, so Zygo may create user namespaces and mount inside them. And a small script arranges the container’s own cgroup tree at every start, because cgroup v2 hands controllers only to a cgroup with no processes in it. zygo in that container is a wrapper that starts the real binary in the empty cgroup the script left for it.

  /sys/fs/cgroup   (the container's own root: memory, pids, cpu for children)
  ├── init/        every process the container started, and each new shell
  └── launch/      empty until zygo starts in it
      └── zygo.slice/ …  the sandboxes, as on a host

What does not work there: the vm backend (no /dev/kvm), and on a host whose kernel is older than 5.13, Landlock; zygo doctor names both. It is a development machine, not a production shape — chapter 16 covers running Zygo in a container for real, without --privileged.

How Zygo runs on a Mac

macOS has no namespaces or cgroups, so a Mac cannot build a Linux sandbox itself. Zygo solves this by keeping a small Linux virtual machine (VM): a whole computer simulated in software, with its own Linux kernel. You never log into it. The zygo command on your Mac passes every sandbox command to a Linux zygo inside the VM, with the same arguments, the same working folder and the same input and output. The exit status comes back out to your shell.

  your Mac                                     Lima VM "zygo" (Ubuntu 24.04)
  ┌───────────────────────────────┐            ┌─────────────────────────────────┐
  │ $ zygo run python:3.12 ...    │   SSH      │ zygo run python:3.12 ...        │
  │   zygo (macOS shim) ──────────┼───────────▶│   └─▶ sandbox (namespaces,      │
  │                               │  ~22 ms    │        cgroup, seccomp ...)     │
  │ output + exit status ◀────────┼────────────┼── output + exit status          │
  │                               │            │                                 │
  │ /Users/you/project  ◀─────────┼── same ────┼─▶ /Users/you/project            │
  │                               │   path     │   (your home, mounted inside)   │
  └───────────────────────────────┘            └─────────────────────────────────┘

The Mac VM in detail

Namezygo, managed by Lima
SystemUbuntu 24.04
Size2 CPUs, 4 GiB of memory, 20 GiB of disk
First startabout a minute: the VM is created on the first command that needs it
Later startsabout 16 seconds, after the VM was stopped
Cost per command, once it is upabout 22 ms, over the SSH connection Lima already holds
Stopping itzygo stop --all stops everything, the VM included

Your home folder is mounted inside the VM at the same path, and it is writable. So ./handler.py is one file, seen from two sides. That is also the limit, and Zygo enforces it: every host path must be under $HOME. A command run from outside your home folder is refused, and the message names both folders instead of quietly running somewhere else.

A few commands never need Linux, so they run on the Mac itself: zygo completion, zygo doctor, zygo agent test and zygo api --openapi. If the VM cannot be reached, a command exits with status 111; chapter 22 says what to do.

What the Mac VM costs

A one-shot zygo run python:3.12-slim true typed in a Mac shell takes about 29 ms end to end, when a supervisor is running in the VM. About 6 ms of that is the sandbox, and about 22 ms is the trip into the VM. true starts no interpreter; the 12 ms quoted elsewhere for a one-shot run is python3 -c pass, with Python’s own start inside it. These are medians of nine runs on the Mac that chapter 25 names. The same run through Docker Desktop on the same Mac took 397 ms.

Why does a one-shot run care about a supervisor? For its cgroup. Without one, zygo run on a systemd login must make a transient scope of its own and re-execute itself inside it, which costs about 12 ms more (25.7 against 13.5 ms inside the Lima VM, chapter 25). With a supervisor running, the run is handed to its cgroup tree instead.

The trip is paid per command, not per request. The millisecond warm path is still there when you call functions through the HTTP API or the SDKs, because then the calls happen inside the VM and the connection pays the trip once. zygo api running inside the VM is the answer for anything that must be fast on a Mac.

The Mac VM from a checkout

Without Homebrew you need the same two parts: Lima, and a Linux build of Zygo to put inside the VM.

brew install lima            # what starts the VM
make guest-build             # the Linux build that runs inside it, compiled in the VM

make guest-build needs nothing but the VM. It installs a Rust toolchain inside the VM on first use. On a Mac that already has Docker, make tests/linux/bin/zygo-linux-musl builds the same binary in a Docker container instead.

Checking the host: zygo doctor

zygo doctor is the first thing to run on any new machine. It checks everything a sandbox needs, prints one line per check, and prints the fix for anything that is missing. It tries each thing rather than reading a setting. For example, it really builds a user namespace and mounts inside it. So a kernel that has a feature switched on but ignores it is caught here, not later inside a sandbox.

zygo doctor           # can this host run sandboxes?
zygo doctor --json    # the same report, for a script or a health check

On a Mac, doctor checks both sides: the Mac’s side (can it reach the VM?) and the VM’s kernel. You get one report with one verdict.

What doctor checks

CheckWhat it asks
kernelIs the kernel 5.3 or newer? Below 6.1 it says degraded.
kernel ageHow old is this kernel series? An old one is behind on hardening, and one out of upstream support may lack security fixes.
user namespacesCan a normal user create one and mount inside it?
procfs (fully visible)Can a fresh /proc be mounted inside a sandbox?
cgroup v2Is the unified cgroup tree there, with controllers delegated to you?
cgroup movesOn Linux 6.0+, is cgroup2 mounted with favordynmods? Without it, about 1 warm request in 100 waits several ms (chapter 22).
systemd OOM policyWould the systemd unit that holds the sandboxes be stopped when one of them is OOM-killed? degraded under the default OOMPolicy=stop (chapter 16).
overlayfs (userns)Can image layers be stacked inside a user namespace?
landlockWhich Landlock version (ABI) does the kernel offer?
seccompCan a seccomp filter be installed?
subuid/subgidDoes your user own a range of extra user ids to map?
kvmIs /dev/kvm there, for the vm backend?
guest kernelIs a kernel for the vm backend’s guest available?
runscIs gVisor installed, for the gvisor backend?
egress (pasta + nft + tc)Are the programs that give a sandbox a filtered network present?

The last lines of the report list the backends this host can use right now: the host has what each one needs and this build of Zygo includes it.

Reading the report

Each line ends in one of four words. The exit status of zygo doctor is 0 exactly when no line says FAIL, and the JSON field ok says the same thing.

StatusMeaning
okPresent and working.
degradedWorks, but something is missing or old, and a feature is weaker or slower.
absent (shown as -)An optional part is not there, such as kvm. Nothing is broken.
FAILSandboxes cannot run until this is fixed. The fix is printed under the line.

network = "none", the default, needs no networking tools at all. So a missing pasta is absent, not FAIL, and only matters when you turn a sandbox’s network on.

Which kernel version gives what

Zygo needs kernel 5.3 or newer. Later kernels unlock extra features rather than block Zygo. 6.1 or newer is recommended, because from there cgroup.kill and memory.peak are both present. Landlock’s network rules come later, in 6.7: below it, bind and connect are limited by the network namespace and the firewall alone.

  5.3 ─────────── 5.11 ─────────── 5.13 ─────────── 6.1 ─────────── 6.7 ─────────▶
  minimum         overlayfs in a   Landlock         recommended:    Landlock
  to run          user namespace   (file access     cgroup.kill,    network rules
                  (below: layers   rules)           memory.peak
                  are flattened,
                  more disk, slower
                  first run)

Letting doctor fix things: --fix

zygo doctor --fix applies the fixes it knows. It first prints a plan: each change, why it is needed, what it costs, and the exact commands. Then it asks apply these? [y/N]. Nothing happens unless you type y. In a script, where nobody can answer, add --yes.

zygo doctor --fix           # show the plan, ask, then apply
zygo doctor --fix --yes     # the same, without asking
What it can fixWhat it doesNeeds root
The AppArmor block on user namespacesInstalls an AppArmor profile, /etc/apparmor.d/zygo, that lets the zygo binary alone create user namespaces; the restriction stays on for every other program. Only where AppArmor cannot load a profile: sets kernel.apparmor_restrict_unprivileged_userns=0 now and in a file under /etc/sysctl.d/, so it stays after a reboot — machine-wide in that caseyes, through sudo
No cgroup delegationWrites ~/.config/systemd/user/user@.service.d/delegate.conf and reloads your systemd user managerno
Missing pasta or nftInstalls the passt and nftables packages with apt, dnf, pacman or apkyes, through sudo
The AppArmor profile that blocks pastaRuns aa-complain on the pasta profile, so it logs instead of blocksyes, through sudo
Slow cgroup moves (cgroup moves … degraded)Remounts cgroup2 with favordynmods now, and installs zygo-cgroup-favordynmods.service to do it at every boot. Every fork and exit gets slightly slower. Machine-wide. Not offered inside a containeryes, through sudo

Ubuntu and Debian: two AppArmor rules

AppArmor is a Linux security module that limits what programs may do. Ubuntu and Debian ship two AppArmor rules that get in Zygo’s way. Both come from the distribution, not from Zygo.

The first is kernel.apparmor_restrict_unprivileged_userns=1. It lets a normal process create a user namespace, and then refuses the first mount inside it. That mount is the first thing every sandbox does. zygo doctor finds this by trying the mount, and zygo doctor --fix installs an AppArmor profile that lets the zygo binary alone past it. The restriction stays on for every other program. Chapter 23 says what the rule protects.

The second is an AppArmor profile for pasta, the program Zygo uses to give a sandbox a network. Where that profile is enforced, it keeps pasta out of the sandbox’s user namespace. Then network = "egress" and "full" cannot start, even though /dev/net/tun works. The error says so and names aa-complain. The default, network = "none", does not use pasta at all.

cgroup delegation, the usual gap

On a machine with systemd, delegation is the most common missing piece, and it has two halves. First, your user manager (the systemd process that looks after your login) has to pass the controllers down to you. This is the fix doctor --fix writes, and you can also do it by hand:

mkdir -p ~/.config/systemd/user/user@.service.d
printf '[Service]\nDelegate=cpu cpuset io memory pids\n' \
    > ~/.config/systemd/user/user@.service.d/delegate.conf
systemctl --user daemon-reexec

Second, the process that runs Zygo has to sit in a delegated cgroup, and a login shell over SSH does not. Zygo handles this half itself, the way Podman does. A command that builds a sandbox restarts itself inside a temporary systemd scope of its own, which is a delegated cgroup made for one command. Chapter 12 has the details.

  your SSH login shell's cgroup      (not delegated: cannot hold a sandbox)
     │
     └─ zygo run ...  ──re-exec──▶  systemd-run --user --scope -p Delegate=yes
                                        └─ zygo run ...   (delegated: builds the sandbox)

One result surprises people. zygo doctor run from a plain SSH session can report cgroup v2 … FAIL, because that is true of the cgroup it stands in. zygo run from the same shell still works, because it moves into a scope first. Chapter 22 has the rest.

Your first one-shot sandbox

A one-shot sandbox is built for one program and thrown away when the program ends. This is zygo run, and it reads like docker run:

zygo run python:3.12-slim python3 -c 'print("hello")'

The first run downloads the image, just as docker run does. After that, a run takes about 12 ms (median, with the image already pulled, measured on the hosts in chapter 25). The sandbox has a read-only root, no network, no capabilities, and memory, CPU and process limits. A few more to try:

zygo run --mem 128M --pids 16 --timeout 10s alpine:3 /bin/sh   # tighter limits
zygo run --tty alpine:3 /bin/sh                                # with a terminal of its own
zygo run --dry-run --json python:3.12-slim                     # the plan, without running it

Chapter 12 covers zygo run in full: mounts, environment, exit codes and more.

Your first warm function

A warm function is a sandbox that is built once and kept ready, so each request skips the start-up. You write a handler: a function that takes one event and returns a result.

mkdir demo && cd demo
cat > handler.py <<'EOF'
def handler(event):
    return {"doubled": event.get("n", 0) * 2}
EOF

zygo serve handler.py --name double      # python:3.12-slim is in the store from the run above
zygo exec double '{"n": 21}'             # {"doubled": 42}
zygo ps                                  # what is running

serve starts a supervisor in your session if none is running: the background process that keeps warm functions alive. Then it warms a zygote: the Python interpreter with handler.py already imported, waiting. Every exec is a fork() of it, which means a copy made in a moment. So there is no import cost per request and no state left over between requests. Chapter 6 explains the idea.

What the warm function costs

  zygo serve                         zygo exec          zygo exec          zygo exec
  ├─ build sandbox                   ├─ fork            ├─ fork            ├─ fork
  ├─ start Python, import handler    └─ reply           └─ reply           └─ reply
  └─ park the zygote                   ~1.4 ms            ~1.4 ms            ~1.4 ms
     ~150 ms, paid once

On a Raspberry Pi 5, a Python handler that imports nothing is warm in about 150 ms, including starting the supervisor. The median overhead per request is about 1.4 ms, measured on a Lima VM on an Apple M1 Max. Both numbers and their machines are in chapter 25. The same trick works from a one-line file:

echo 'def handler(event): return {"got": event}' > handler.py
zygo serve ./handler.py --name echo
zygo exec echo '{"n": 1}'
zygo bench all            # every published number, measured again on this host

A project with three functions

A real project describes its functions in a file called sandbox.toml, so you do not repeat flags. Here are three functions that each show a different shape: a Python function with system packages, a Go binary, and a function that may call one outside API.

# sandbox.toml
[defaults]
image = "python:3.12-slim"
mem   = "256M"

[fn.resize]
entry        = "./resize.py"
requirements = "./requirements.txt"    # a venv, built once inside the image
system       = ["libwebp7"]            # apt packages, once, as a layer
mem          = "512M"

[fn.parse]
image  = "alpine:3"                     # no runtime → warm-exec
mounts = ["./bin/parse:/app/parse:ro"]  # a static Go binary
cmd    = ["/app/parse"]

[fn.fetch]
entry   = "./fetch.py"
network = "egress"
allow   = ["api.example.com:443", "*.cdn.example.com:443"]
secrets = ["API_KEY"]

[defaults] applies to every function; each [fn.NAME] table adds to it or overrides it. Chapter 20 lists every field.

What each function in the file does

FunctionShapeWhat is special
resizePython handler (entry)requirements becomes a Python venv (a folder of installed packages), built once inside the image. system installs apt packages once, as an extra image layer. It gets 512 MB instead of the default 256 MB.
parsewarm-exec (cmd)A Go binary starts fast, so only the sandbox is kept warm and each request runs the program fresh. It uses alpine:3 because it needs no Python.
fetchPython handler with networknetwork = "egress" opens only the names and ports in allow. API_KEY is read from your shell and given to each request as a file, never as an environment variable.
  sandbox.toml
     │
     ├─ [fn.resize] ──▶ python:3.12-slim + libwebp7 layer + /venv ──▶ zygote: fork per request
     ├─ [fn.parse]  ──▶ alpine:3 + your Go binary, mounted ─────────▶ sandbox: exec per request
     └─ [fn.fetch]  ──▶ python:3.12-slim, network: only the allow list,
                        /run/secrets/API_KEY per request ───────────▶ zygote: fork per request

Chapter 13 covers the shapes, chapter 14 the network and secrets, and chapter 15 the venv and the apt layer.

Bringing the project up

zygo pull alpine:3                     # up never pulls; python:3.12-slim is already there
export API_KEY=…                       # read from this shell, delivered as a file
zygo up                                # every [fn.*] warm
zygo exec fetch '{"path": "/v1/ping"}'
zygo logs fetch -f                     # follow the function's log
zygo down                              # stop them all

zygo up warms every function in the file. It never pulls an image — a deploy should not quietly depend on a registry — so pull first, as above; zygo run is the one command that pulls for you (chapter 15). Run up again after an edit, and only the functions that changed are restarted.

Calling the functions over HTTP

zygo api puts an HTTP server in front of the functions. By default it listens on 127.0.0.1:7700, and every caller needs a bearer token: a secret string sent in the Authorization header.

export ZYGO_API_TOKEN=$(openssl rand -hex 16)
zygo api                               # 127.0.0.1:7700

curl -H "Authorization: Bearer $ZYGO_API_TOKEN" \
     -d '{"n": 4}' http://127.0.0.1:7700/fn/double

The token is needed on every kind of listener, unix sockets included. --no-auth turns it off, and Zygo refuses --no-auth anywhere except a unix socket or a loopback address such as 127.0.0.1.

  curl / your app ──HTTP──▶ zygo api ──▶ supervisor ──▶ double  (zygote)
      Authorization:        127.0.0.1:7700              resize  (zygote)
      Bearer <token>                                    parse   (warm sandbox)
                                                        fetch   (zygote)

What the API answers

Route or statusMeaning
POST /fn/<name>Run one request with the body as the event.
POST /fn/<name>/batchRun a list of events.
GET /metricsNumbers in Prometheus text format, for a monitoring system to collect.
408The request ran past its deadline.
429This tenant’s queue is full. The Retry-After header says when to try again.

Chapter 17 has every route, status code and header, and the Python, Node and Elixir clients.

Sending metrics to OpenTelemetry

Instead of waiting to be scraped at /metrics, Zygo can push the same numbers to an OpenTelemetry collector. OpenTelemetry (OTel) is a common standard for sending metrics and traces to monitoring tools.

zygo api --otlp-endpoint http://localhost:4318

OTEL_EXPORTER_OTLP_ENDPOINT does the same as the flag. The numbers go as OTLP/HTTP JSON once a minute, and OTEL_EXPORTER_OTLP_HEADERS adds a header, for example for authentication. Only metrics are sent. There are no per-request traces (spans) yet.

When something is wrong

zygo logs fetch --failed                    # the requests that failed, with their stderr
zygo shell fetch                            # a shell inside the warm sandbox
zygo spec explain fetch                     # every limit and mount, fully resolved
zygo run --dry-run --json python:3.12-slim  # the mount plan, without running it
zygo doctor                                 # is the host still able to run sandboxes?

Chapter 22 lists the errors people actually meet, with the fix for each.

Where to go next

If you want to…Read
see all three ways working in one small appexamples/web-api — a web API whose three endpoints use a warm function, a pool and a fresh sandbox
use zygo run well12. One-shot sandboxes
write handlers in more languages13. Warm functions
set limits, network and secrets14. Limits, network and secrets
understand images, venvs and apt layers15. Images and dependencies
run it on a server16. Production
call it from a program or an AI agent17. API, SDK and MCP
look up a field of sandbox.toml20. sandbox.toml
see measured speed and memory25. Performance
compare with Docker, gVisor and Firecracker10. Similar projects
read the security model23. Security

The examples/ folder has complete projects: a small web API, a webhook, a CI job, an LLM tool, a Go program and a shell script as warm-exec functions, a plugin host in Python and in Node, a workflow-engine worker, an n8n Code-node runner, a Kubernetes deployment, and an agent in POSIX sh. Its README says what each one shows.

12. One-shot sandboxes: zygo run

zygo run builds a fresh sandbox, runs one program in it, and removes the sandbox when the program ends. It reads like docker run, but it has safe defaults and leaves nothing behind. This chapter covers everything about it: what happens inside, how to give the sandbox your files, and how to tell why it ended.

The shape of the command

zygo run [FLAGS] IMAGE [COMMAND ARGS...]
zygo run python:3.12-slim python3 -c 'print("hello")'

The image is a normal container image from any registry, such as Docker Hub. Everything after the image is the command, which runs inside the sandbox. With no command, the image’s own entrypoint runs, just as with Docker. Put Zygo’s flags before the image; anything after it belongs to the command.

What happens, step by step

  zygo run --mem 128M python:3.12-slim python3 app.py
     │
     ├─ 1. plan    read the flags and any sandbox.toml, check the limits,
     │             find the image in the store (pull it if missing)
     │
     ├─ 2. place   supervisor running? ── yes ─▶ hand the sandbox to it
     │                    │ no
     │                    └─▶ not in a delegated cgroup? re-exec in a systemd scope
     │
     ├─ 3. start   clone3 into new namespaces ─▶ the child builds its root,
     │             joins its cgroup, drops capabilities, adds Landlock + seccomp,
     │             and calls execve on your command
     │
     ├─ 4. run     your program runs; streams, signals and exit code pass through
     │
     └─ 5. end     the kernel removes the namespaces and the tmpfs,
                   Zygo removes the cgroup ─▶ nothing is left

These are the three phases that --outcome reports: plan (step 1), start (steps 2 and 3), and run (steps 4 and 5). Namespaces, cgroups, capabilities, Landlock and seccomp are explained in chapters 2, 3 and 4, and chapter 6 shows how Zygo puts them together.

What the sandbox has

Every sandbox starts closed. You open what you need, one flag at a time.

It hasDefault
A root filesystemThe image’s layers, read-only
A writable /tmpSized by --scratch: the smaller of 64 MB and half of --mem
A home folder/tmp, unless the image or you set HOME, so pip and npm caches have a place to write
Memory--mem 256M
CPU--cpu 1.0, one full core
Processes--pids 64
Time--timeout 30s
Open files--nofile 1024
Networknone at all (--net none)
Capabilitiesnone
System callsthe default seccomp allowlist

What it does not have: your files, your network, your other processes, or any way to reach the image store it was built from.

The first run and the second

The first run of an image pulls it, just as docker run does. After that, the image is in Zygo’s store under your home folder, and a run takes about 12 ms (median, image already pulled; the hosts are in chapter 25). The first run on a kernel older than 5.11 is also slower, because Zygo has to flatten the image’s layers once. Pull policy below explains how to control pulling.

Running your own code: mounts

The sandbox cannot see your filesystem. So a script of yours has to be mounted in first. A mount makes a file or folder from your machine appear at a path inside the sandbox. The interpreter, by contrast, comes from the image, not from your machine — chapter 6 explains why zygo ./venv/bin/python app.py is not a thing.

zygo run --mount ./hello.py:/hello.py:ro python:3.12-slim python3 /hello.py
zygo run --mount ./src:/src:ro python:3.12-slim python3 /src/main.py --flag
printf '{"n": 21}' | zygo run --mount ./src:/src:ro python:3.12-slim python3 /src/double.py

The form is --mount HOST:GUEST[:ro|rw]. HOST is the path on your machine, GUEST is the path inside the sandbox. A mount is read-only unless you add :rw. You can mount a single file or a whole folder, and you can repeat --mount as often as you need.

  your machine                          the sandbox
  ./src/main.py     ── --mount ─────▶   /src/main.py    (read-only)
  ./pkgs/           ── --mount :rw ─▶   /pkgs/          (writable, changes are real)
  everything else   ── not visible ──   (the sandbox cannot name it)

Programs from your host

You can run a program from your own machine the same way: mount it, then name it. It runs against the image’s libraries, not your host’s. So a program that is dynamically linked (it loads shared libraries such as libc when it starts) needs an image with a compatible libc. A static program carries its libraries inside it and runs anywhere.

zygo run alpine:3 pwd                                             # the image's own pwd: /
zygo run --workdir /tmp alpine:3 pwd                              # /tmp
zygo run --mount /bin/pwd:/opt/pwd:ro python:3.12-slim /opt/pwd   # your host's pwd

A glibc /bin/pwd from Ubuntu runs in python:3.12-slim, which also uses glibc. The same file fails in alpine:3 with “the program does not exist”, because Alpine uses musl and the loader the program names is not there.

Working folder, user and environment

FlagWhat it sets
--workdir DIRThe working folder inside the sandbox.
--user UIDThe user id the program runs as inside the sandbox. The default is 1000.
--env KEY=VALUEOne environment variable. Repeat it for more.
zygo run --env GREETING=hi --workdir /tmp alpine:3 sh -c 'echo $GREETING from $(pwd)'

Do not pass secrets with --env. Environment variables are easy to leak, for example into logs or child processes. Warm functions have a safer way, described in chapter 14.

Limits on the command line

zygo run --mem 128M --pids 16 --timeout 10s alpine:3 /bin/sh
zygo run --cpu 0.5 --scratch 32M --nofile 256 python:3.12-slim python3 app.py
FlagLimitsExample
--memmemory512M
--cpuCPU time, in cores0.5
--pidsnumber of processes (stops a fork bomb: a program that copies itself without end)16
--timeoutwall-clock time10s
--scratchsize of the writable /tmp32M
--nofileopen files256

A limit cannot be switched off unless you also pass --allow-unlimited. Chapter 14 explains each limit.

Isolation, seccomp and network flags

FlagWhat it chooses
--isolation ns|gvisor|vmThe backend: the kind of wall around the program. ns is the default. See chapter 6 for how each works and chapter 16 for choosing.
--seccomp default|strict|permissiveWhich list of system calls is allowed. See chapter 24.
--net none|egress|full|hostThe network. none is the default.
--allow HOST:PORTWith --net egress, one name and port the program may reach. Repeatable.
--allow-host-netPermits --net host, which removes the network wall.
--allow-private-netPermits --allow rules that point at private or link-local addresses.
zygo run --net egress --allow api.example.com:443 python:3.12-slim python3 /src/call.py

Each flag that makes the sandbox weaker has a name that says so. You cannot remove a protection by accident.

Input, output and signals

Standard input, standard output, standard error and the exit code pass straight through, so zygo run fits in a shell pipe. Zygo’s own messages, such as pulling python:3.12-slim, go to standard error. -q or --quiet hides them, which helps when a program or an AI model reads the output. It never hides what the program itself writes, and errors are still reported.

Ctrl-C works in two steps. The first Ctrl-C (or SIGTERM, or SIGHUP) is passed on to the program and every process it started, so a Python program gets its KeyboardInterrupt. The second signal of any kind sends SIGKILL to the sandbox’s first process, which ends the whole sandbox.

  Ctrl-C #1 ──▶ SIGINT to the program and its process group   ("please stop")
  Ctrl-C #2 ──▶ SIGKILL to the sandbox's pid 1 ─▶ whole sandbox gone   ("stop now")

A signal that the shell told zygo to ignore, for example under nohup, is not passed on.

A terminal of its own: --tty

Zygo has no daemon, so by default the sandbox inherits your terminal. That is why zygo run alpine:3 sh feels like a local command: colours, prompts and job control just work. The cost is that code in the sandbox holds a writable handle to your real terminal.

-t or --tty gives the sandbox a pseudo-terminal of its own instead: a fake terminal that Zygo creates. Zygo keeps the other end and copies bytes between it and your real terminal, so the sandbox never touches the real one.

  default:   your terminal ◀──────────────────────────▶ sandbox (holds your terminal)
  --tty:     your terminal ◀──▶ zygo ◀──▶ new pty ◀──▶ sandbox (never sees yours)
zygo run --tty alpine:3 /bin/sh

Why is --tty not the default? Because the default is already safe, and it is what makes a sandbox feel like a local command. The dangerous use of that handle, pushing keystrokes into your terminal with TIOCSTI, is blocked by the seccomp filter either way. --tty goes one step further and removes the handle completely. A run with --tty always stays in your shell and is never handed to a supervisor.

Coming from Docker

The sentence is the same as docker run, the defaults are the opposite, and no container object is left behind for you to remove. Chapter 5 and chapter 10 compare the two flag by flag.

One trap: in Zygo, -v means verbose, not volume. If you type zygo run -v $PWD:/src image in the folder /app, Zygo sees that the “image” looks like a mount and says so:

`/app:/src` is a mount, not an image
  → `-v` means verbose in Zygo, not volume; use: zygo run --mount /app:/src <image> …

Look before you run: --dry-run

zygo run --dry-run --json python:3.12-slim

--dry-run prints the plan and runs nothing. It shows:

  • the resolved settings, after defaults, sandbox.toml and flags are merged;
  • the mount plan: every path the sandbox will see, and where it comes from;
  • the seccomp profile, where it came from (the flag, the spec or the default), and how many system calls it allows;
  • whether Landlock applies on this host;
  • the cgroup values that will be written, and how the deadline is enforced.

This is how you review a sandbox’s walls before you trust them. --json makes the plan machine-readable, so two plans can be compared with diff.

Exit codes

The exit code is the program’s own, with a few exceptions that belong to Zygo.

StatusMeaning
the program’s ownIt ran, and this is what it returned.
137Zygo or the kernel killed it: the deadline or the memory limit. --outcome says which.
2The spec or the flags are wrong.
125This host cannot run sandboxes, or the chosen backend is not available. zygo doctor says why.
1Any other Zygo error, such as a missing image under --pull never.
111macOS only: the Linux VM could not be reached. See chapter 22.
75zygo exec only: the function is at its concurrency limit. Retry.
4zygo exec only: no function has that name.

A program can also return 1, 2 or 125 itself. When the difference matters, use --outcome.

Knowing why a sandbox ended: --outcome

A deadline kill and an out-of-memory kill both exit 137. Both are a SIGKILL, and the exit status carries nothing more. When the difference matters, for example in an online judge or a CI step, ask for an outcome file:

zygo run --outcome /tmp/why.json --mem 64M --timeout 5s python:3.12-slim python3 big.py
cat /tmp/why.json
{"exit_code":137,"timed_out":false,"oom_killed":true,"peak_rss_kb":65780,
 "wall_ms":412.7,"plan_ms":3.1,"start_ms":9.8,"started":true,"phase":"run"}

The three times in this example are only an illustration; they depend on the host. The report goes to a file because standard output belongs to the program. Zygo writes the file whole and then renames it into place, so a reader never sees half a file.

The outcome fields

FieldMeaning
exit_codeThe exit status, as above.
timed_outThe deadline killed it. This comes from the launcher, which enforced the deadline.
oom_killedThe kernel killed something for using too much memory. This comes from the kernel’s own counter in the sandbox’s cgroup.
peak_rss_kbThe most memory it used at once, in KB.
wall_msThe program’s own time, from execve to exit, including tearing the sandbox down.
plan_msTime before the sandbox: reading the spec, the image store, checking the host.
start_msTime to build the sandbox, up to and including execve.
startedWhether the program ran at all.
phaserun if the program ran; otherwise the phase that failed, plan or start.

Neither timed_out nor oom_killed is a guess. The three times tell you which part was slow when one run takes much longer than usual.

When the sandbox never started

The outcome file is also written when the sandbox never started: the image is not there, the host cannot run sandboxes, or a mount does not exist. Then it says "started": false and names the phase that failed. A program that ran has "started": true and "phase": "run".

  "started": false, "phase": "plan"   ─▶ not set up (missing image, bad spec)   ─▶ unavailable
  "started": false, "phase": "start"  ─▶ the host could not build the sandbox   ─▶ unavailable
  "started": true,  "phase": "run"    ─▶ the code ran; exit_code is its own     ─▶ its own result

This is the difference between the service is unavailable and the code failed. The exit status cannot carry it, and a caller should not have to read standard error to find it.

Pull policy

--pull says when zygo run downloads the image.

ValueBehaviour
missing (default)Pull only when the image is not in the store, like docker run.
neverNever pull. A missing image is refused before anything starts: exit 1, and the outcome file says "phase": "plan".
alwaysPull every time, to pick up a tag that has moved to a new image.

never is for a caller that keeps its own clock, such as a judge with a time limit. A pull takes minutes and a run takes seconds. Without never, a run that quietly became a pull looks like a program that was too slow. With it, the answer comes in a millisecond. Pull images ahead of time with zygo pull IMAGE.

Python packages: --requirements

zygo run --requirements ./requirements.txt --mount ./src:/src:ro \
    python:3.12-slim python3 /src/main.py

--requirements FILE installs the packages in a Python venv (a folder of installed packages) and mounts it at /venv, with /venv/bin first on PATH. The venv is built once and cached. The cache key is the image’s digest plus the bytes of the file, so the next run with the same image and the same file reuses it. It is the same cache zygo serve uses, so a one-shot run and a warm function with the same requirements share one venv. Chapter 15 explains the cache.

Installing packages into a mounted folder

Many products need an “install this customer’s packages” feature. The shape is always the same: one networked one-shot installs into a mount, and then any number of sandboxes without network import from it.

mkdir -p ./pkgs
zygo run --net full --mount ./pkgs:/pkgs:rw python:3.12-slim \
    python3 -m pip install --target /pkgs python-dateutil
zygo run --mount ./pkgs:/pkgs:ro --env PYTHONPATH=/pkgs python:3.12-slim \
    python3 -c 'import dateutil, six; print(dateutil.__version__)'
  step 1 (once):   network on  ──▶ pip install ──▶ ./pkgs  (mounted rw)
  step 2 (often):  network off ──▶ import from ──▶ ./pkgs  (mounted ro)

What the package install costs

On the 2-core VM of an adoption test (a real multi-tenant product moved onto Zygo), python-dateutil and six installed in 2.8 s through Zygo, against 3.9 s through Docker.

This used to fail with [Errno 1] Operation not permitted on a RECORD file. pip install --target copies each file with Python’s shutil.copy2, which calls listxattr (a call that lists a file’s extended attributes), and the seccomp profiles did not allow it. The workaround was to point TMPDIR at the same mount. Every profile now allows the whole extended-attribute family, so neither the error nor the workaround remains, and make verify-seccomp-profiles-linux checks that it stays that way.

Using a sandbox.toml: [defaults] and -f

zygo run does not need a spec file; the flags alone are enough. But if a sandbox.toml exists, zygo run uses it. It looks in the current folder and then in each parent folder, or reads the file you name with -f.

  built-in defaults ──▶ the spec's [defaults] ──▶ your flags   (later wins)
                        ([fn.*] tables are ignored by zygo run)
zygo run -f ./ci/sandbox.toml python:3.12-slim python3 -m pytest

So a team can write its limits once in [defaults], and every zygo run in that project uses them. The [fn.*] tables describe warm functions and are ignored here. Chapter 20 describes the file.

Handing the run to the supervisor

On Linux, when a supervisor is already running (for example after zygo serve), zygo run gives the sandbox to it. The supervisor already sits in a delegated cgroup, so the run skips the systemd scope described below. Planning and pulling still happen in your process; the supervisor only builds and runs the sandbox, and your process passes its streams and signals and waits.

zygo run python:3.12-slim python3 -c pass, usually
In its own systemd scope25.7 ms
Through a running supervisor13.5 ms
Measured onUbuntu 24.04 VM, kernel 6.8

The hand-off does not happen with --dry-run (nothing runs), with --tty (the terminal must stay in your shell’s session), or with an isolation other than ns. zygo run -v tells you which path was taken: its timing line ends in (through the supervisor) when the supervisor ran it.

The systemd scope

On a systemd machine, a login shell lives in a cgroup that you are not allowed to split up. A sandbox needs a cgroup of its own for its limits, and Zygo refuses to run a sandbox with no limits. So zygo run, pull, serve, up and bench first check where they stand. If they are not in a delegated cgroup, they start again inside a temporary one:

  $ zygo run ...                         (login shell cgroup: not delegated)
      └─▶ systemd-run --user --scope -p Delegate=yes zygo run ...
             └─▶ zygo run ...            (own scope: delegated, can make cgroups)
                    └─▶ sandbox

This costs about 10 to 15 ms: the scope, a second process, and a cgroup tree built and removed. In a container, a systemd service, or a session that is already delegated, nothing is done. A guard stops Zygo from restarting itself forever: if the new scope is still not usable, you get Zygo’s own error.

Output for scripts

FlagWhat it does
--json (global)Machine-readable output, for example with --dry-run.
-v (global, repeat for more)More detail from Zygo, including the timing line.
-q, --quietHide Zygo’s own progress messages.
--data-root DIR (global)Keep the image store and state in DIR instead of the default under your home folder.
--outcome FILEWrite why the sandbox ended, as JSON.

Every zygo run flag in one table

FlagSection
--mem --cpu --pids --timeout --scratch --nofileLimits
--isolation --seccomp --net --allowIsolation, seccomp and network
--allow-host-net --allow-private-net --allow-unlimitedLimits and network
--mount HOST:GUEST[:ro|rw]Mounts
--env --user --workdirWorking folder, user and environment
-t, --ttyA terminal of its own
--requirements FILEPython packages
--pull missing|never|alwaysPull policy
--outcome FILEKnowing why a sandbox ended
--dry-runLook before you run
-q, --quietOutput for scripts
-f, --fileUsing a sandbox.toml

Chapter 19 lists every other command.

13. Warm functions

A warm function is a sandbox that Zygo builds once and keeps ready, so that each request costs a fork() instead of a new sandbox. This chapter shows how to serve one, call it, watch it and stop it, and what your code must look like in Python, Node, TypeScript or any other language. Chapter 6 explains the idea behind it; this chapter is about using it.

Warm functions in one picture

A handler is the function you write: it gets one request and returns one answer. zygo serve builds a sandbox, starts a small helper program inside it called the agent, and the agent loads your handler. That loaded, waiting process is the zygote. zygo exec sends a request; the zygote makes a copy of itself with fork(), and the copy runs your handler once and exits.

  zygo serve ./handler.py --name resize        (once, about 150 ms)
        │
        ▼
  ┌────────────────────── warm sandbox "resize" ───────────────────────┐
  │                                                                    │
  │   zygote: python started, imports done, handler loaded             │
  │      │                                                             │
  │      ├── fork ─▶ child: handler(event 1) ─▶ answer ─▶ exit         │
  │      ├── fork ─▶ child: handler(event 2) ─▶ answer ─▶ exit         │
  │      └── fork ─▶ child: handler(event 3) ─▶ answer ─▶ exit         │
  │                                                                    │
  └────────────────────────────────────────────────────────────────────┘
        ▲
        │
  zygo exec resize '{"url": "…"}'             (each time, about 1.4 ms)

Why this is the production shape

A one-shot sandbox (zygo run) costs about 12 ms with Python in it: about 3.6 ms is the sandbox itself, the rest is Python starting. A warm function pays the setup once and then costs about 1.4 ms a request: that is the median overhead measured on a 2-vCPU Lima VM on an Apple M1 Max (chapter 25). The gap is easy to miss. zygo run looks like the natural way to say “run this code once”, so a program that uses Zygo that way gets one sandbox per event. On that VM, zygo bench cold says 12.3 ms for that, while zygo bench warm says 1.44 ms and 1,108 requests a second — more than eight times faster, and the gap grows with every module the handler imports.

A multi-tenant application that starts with run for exactly that reason should expect to move to warm functions once it counts the events. The multi-tenant example below shows what that looks like.

  one sandbox per event (zygo run)          one warm function (zygo serve + exec)
  ────────────────────────────────          ──────────────────────────────────────
  event ─▶ build sandbox ─▶ run ─▶ clean    serve: build sandbox + load code, once
  event ─▶ build sandbox ─▶ run ─▶ clean    event ─▶ fork ─▶ run ─▶ exit
  event ─▶ build sandbox ─▶ run ─▶ clean    event ─▶ fork ─▶ run ─▶ exit
  12.3 ms each (2-vCPU Lima VM)             1.44 ms each (same VM)

Serving a function

zygo serve takes a handler file and a name. It starts the supervisor (the background process that owns every warm sandbox) if one is not running yet, warms the sandbox, and returns. From then on the function is ready.

zygo serve ./handler.py --name resize
zygo serve ./handler.py --name resize --requirements requirements.txt --mem 512M
zygo serve ./report.py  --name report --secret STRIPE_KEY --idle-timeout 5m
FlagWhat it does
--name NThe name you call the function by.
--image IThe image to warm from. The default comes from the runtime: python:3.12-slim or node:22-slim.
--requirements FILEA dependency file, installed once into a shared, cached /venv.
--concurrency NHow many requests may run at the same time in one zygote. Default 4.
--idle-timeout DPause the zygote after this long with no requests. Default 10m.
--mode function|stdinHow the handler is called; see mode = "stdin".
--secret NAMEDeliver the secret NAME, taken from this shell’s environment, as /run/secrets/NAME. Repeat for more.
-f PATHRead this sandbox.toml instead of searching upwards for one.

Every limit and sandbox flag of zygo run works here too: --mem, --cpu, --pids, --timeout, --net, --allow, --mount and the rest. They are described in chapter 14. The same settings can live in a [fn.<name>] table of sandbox.toml (chapter 20).

Calling a function

zygo exec NAME EVENT sends one request. The event is any JSON value; if you leave it out, zygo exec reads it from standard input. The handler’s answer goes to standard output. Whatever the handler itself printed goes to standard error, so you can pipe the answer into another program and still see the logs.

zygo exec resize '{"url": "https://example.com/a.png"}'
echo '{"url": "https://example.com/b.png"}' | zygo exec resize
zygo exec resize --timeout 5s '{"url": "https://example.com/c.png"}' > out.json

--timeout gives up on this request after that long; without it, the function’s own timeout applies. The exit code of zygo exec tells a script what happened:

Exit codeMeaning
the request’s ownThe handler finished; 0 is success.
137The request’s deadline killed it.
75The function is busy: every slot and the whole queue are full. Try again later.
4There is no function with that name.
125There is no supervisor running.

Many events at once: --batch

--batch reads NDJSON from standard input: one JSON event per line. It sends them to the function in parallel and prints one JSON answer per line, in the same order as the input. The exit code is 0 only if every request succeeded.

zygo exec resize --batch < events.ndjson > answers.ndjson
  events.ndjson           the function (concurrency 4)          answers.ndjson
  ─────────────           ────────────────────────────          ──────────────
  line 1 ──────────────▶  fork ─▶ answer 1 ─────────────────▶  line 1
  line 2 ──────────────▶  fork ─▶ answer 2 ─────────────────▶  line 2
  line 3 ──────────────▶  fork ─▶ answer 3 ─────────────────▶  line 3
  ...                     (they run side by side,               (always in
                           and may finish in any order)          input order)

When it is full: concurrency and the queue

Each zygote runs up to concurrency requests at once (default 4). More requests wait in a queue that holds up to four times concurrency. When the queue is full too, a new request is turned away at once as busy: HTTP 429 from the API, exit code 75 from zygo exec. Turning work away quickly is better than letting every caller wait for a timeout.

                          concurrency = 4
  new request ─▶ ┌───────────────────────────────┐
                 │ running: [1] [2] [3] [4]      │  full? ─▶ wait in the queue
                 └───────────────────────────────┘
                 ┌───────────────────────────────┐
                 │ queue: up to 16 (4 × 4)       │  full? ─▶ busy: HTTP 429, exit 75
                 └───────────────────────────────┘

Looking after warm functions

These commands work on functions that are already served. Chapter 19 has every flag.

CommandWhat it does
zygo psLists warm sandboxes: name, state (warm, paused, cold), memory, requests.
zygo stop NAMEStops one function. zygo stop --all stops every one.
zygo logs NAMEShows the last 50 log entries: the zygote’s own output and one line per request with its stdout and stderr.
zygo logs NAME -fKeeps printing new entries as they arrive. -n 200 starts with more.
zygo logs NAME --failedOnly requests that failed: a non-zero exit or an error.
zygo shell NAMEOpens a shell inside the function’s sandbox, for debugging.
zygo shell NAME -- ls /appRuns one command there instead of a shell.
zygo topA live table of every function’s resources, updated every 2 seconds.
zygo stats [NAME]A summary of the metrics, for all functions or one.

zygo shell needs one warning. It starts a new process and enters the sandbox’s namespaces (the kernel’s walls around files, processes, network and host name), so it sees what the handler sees. It holds no capabilities. But it is not under the seccomp filter, the Landlock rules or the function’s cgroup, so that a debugging shell is not killed by the memory limit. The warm zygote is not touched and keeps serving.

Warm, paused, cold

A warm function does not stay in memory forever. After idle_timeout (default 10 minutes) with no requests, Zygo pauses it: the cgroup is frozen, so its processes stop using the CPU but stay in memory. The next request wakes it with one write, in far less time than a warm-up. After cold_after (default 1 hour) the sandbox is dropped. The function still exists by name, and the next request pays a full warm-up again: about 150 ms for a Python handler with no imports, measured on a Raspberry Pi 5.

                zygo serve               no request for idle_timeout (10m)
  (nothing) ─────────────▶ ┌────────┐ ──────────────────────────────▶ ┌──────────┐
                           │  warm  │                                 │  paused  │
                           │        │ ◀────────────────────────────── │          │
                           └────────┘   a request wakes it (1 write)  └──────────┘
                               ▲                                           │
                               │ a request warms it again                  │ no request for
                               │ (about 150 ms for Python)                 │ cold_after (1h)
                           ┌───┴────┐                                      │
                           │  cold  │ ◀────────────────────────────────────┘
                           └────────┘   sandbox dropped, name kept
StateIn memory?Uses CPU?Cost of the next request
warmyesonly while servinga fork, about 1.4 ms
pausedyesnoone write to wake it, then a fork
coldnonoa full warm-up, then a fork

Which shape to use

There are three ways to give Zygo code to keep warm. Pick by two questions: is the code the same on every request, and does its runtime start slowly?

                         is your code the same on every request?
                              │
               yes ───────────┴──────────── no: each request brings a script
                │                                        │
      does its runtime start slowly?              RUNTIME POOL
      (Python, Node with many modules)            [runtime.<name>], --runtime
                │                                 one warm interpreter,
        yes ────┴──── no (Go, Rust, C, sh)        thousands of scripts
         │                    │
   AGENT FUNCTION        WARM-EXEC FUNCTION
   entry = "h.py"        cmd = ["/app/bin"]
   fork per request      new process per request
   ~1.4 ms               ~1.4 ms + your program's start

A Python handler

Write a module with a function called handler at the top level. It gets the event — whatever JSON the caller sent, already parsed — and returns something that can be turned into JSON. It may be a normal function or an async one; Zygo runs an awaitable to the end. There is no second “context” argument. Everything at module level runs once, in the zygote, before any request: put your imports, your model loading and your compiled regular expressions there.

import json, re                       # runs once, when the zygote warms
PATTERN = re.compile(r"\d+")          # also once

def handler(event):                   # runs in a fresh fork, per request
    numbers = PATTERN.findall(event["text"])
    return {"count": len(numbers)}

A larger one, which makes image thumbnails:

from PIL import Image          # imported once, in the zygote
import io, base64

MAX = (800, 800)               # module-level state is shared, copy-on-write

def handler(event: dict) -> dict:
    """Runs in a fresh fork per request. Writing to globals is safe, but the
    next request will not see it."""
    img = Image.open(io.BytesIO(base64.b64decode(event["image"])))
    img.thumbnail(MAX)
    out = io.BytesIO(); img.save(out, "WEBP")
    return {"image": base64.b64encode(out.getvalue()).decode(), "size": img.size}

What “warm” means, exactly

The sandbox is built once, and the agent inside it imports your handler. Each request is a fork() of that agent. A fork shares the parent’s memory copy-on-write: the pages are shared until one side writes to one, and only that page is copied. So nothing is copied up front and nothing is imported again. And because the child is a separate process, nothing a request writes is visible to the next one. You get the speed of a shared interpreter and the isolation of a fresh one.

That is the trade Zygo exists to make. A long-running worker is fast but leaks state from one request to the next. A container per request is clean but costs hundreds of milliseconds. A fork is both fast and clean.

What mem bounds in a warm function

mem is one request’s limit. Each request runs in a cgroup of its own with memory.max = mem, and the warm process (the agent, and for Node the workers it keeps loaded) sits in a leaf of its own with the same limit (chapter 3). A request that allocates past mem is killed by the kernel, together with everything it started and nothing else: the requests running beside it finish, and the zygote is still warm for the next one. So a function with concurrency = 4 and mem = "256M" may use up to five times mem at once, one share for the warm process and one per request; the tenant’s budget above bounds the sum. make verify-oom-linux runs three sleeping requests beside one that allocates 2 GB, for both agents, and checks that only the one dies.

  long-running worker          container per request         fork per request (Zygo)
  ───────────────────          ─────────────────────         ───────────────────────
  fast                         clean                         fast AND clean
  request 2 sees what          hundreds of ms each           each child starts as a copy
  request 1 left behind                                      of the zygote, then is gone

A fork carries three things that a fresh process would not. make fork-sweep-linux measured them across 44 popular PyPI packages. The next three sections say what Zygo does about each.

When a handler is not safe to fork

A fork copies only the thread that called it. If another thread held a lock at that moment, the lock stays locked in every child, and the child can hang. So a process that started threads cannot be forked safely. At warm-up the Python agent checks for threads that would survive a fork — Python threads and native ones, such as the four that import duckdb starts. If it finds any, it falls back to spawning a fresh interpreter per request instead of forking. That is correct, but much slower, and zygo logs says when it happened. Thread pools that stop themselves before a fork, as OpenBLAS’s does, do not trigger it. The fix is to start threads inside the handler, or lazily on first use.

Random numbers in a fork

A child starts with a copy of its parent’s random-number state, so without care every request would draw the same “random” numbers. The agent reseeds Python’s random, numpy’s global generator and torch’s generator in every child. It cannot reseed a generator that you created at import time, such as RNG = random.Random() or np.random.default_rng(): every request draws the same numbers from it. The agent names such a generator in zygo logs at warm-up. Create it inside the handler instead.

Work done lazily on first use

Every request is the first call in its own process. So a library that sets itself up on first use pays that cost on every request, and none of it is kept. Do that work at import time instead, where the zygote pays it once: build the boto3 client, compile the template, and create the pydantic model at module level.

A Node handler

Export a function: module.exports = function handler(event) {…} or module.exports.handler = …. It may return a value or a promise; undefined becomes null. One difference to know: Node is not safe to fork, so the Node agent keeps two pre-loaded worker processes ready. Each worker runs one request and exits, and a new one is started off the request path. The effect — a clean process per request — is the same, but the cost per request is a little higher than Python’s fork.

const crypto = require("crypto");          // loaded once, in each pre-loaded worker

module.exports = async function handler(event) {
  return { sha256: crypto.createHash("sha256").update(event.text).digest("hex") };
};
  Node agent (no fork)
  ┌────────────────────────────────────────────────────────────────┐
  │  agent ── keeps 2 workers loaded and parked                    │
  │    worker A: handler loaded ─▶ request 1 ─▶ exit               │
  │    worker B: handler loaded ─▶ request 2 ─▶ exit               │
  │    worker C: started in the background, ready for request 3    │
  └────────────────────────────────────────────────────────────────┘

TypeScript handlers

A .ts file is loaded as TypeScript with its types removed, with no build step and no bundler. The types are stripped as the module loads, in the worker, which is the same place a .js file is compiled. enum, namespaces and parameter properties work too. This needs Node 22.13 or later, or the amaro package in the function’s dependencies. Chapter 18 has the details.

[fn.resize]
image = "node:22-slim"
entry = "./resize.ts"     # runtime = "node", inferred from the extension

What a request sees

The eventThe JSON the caller sent (null for an empty body). In Python a JSON object arrives as a dict with one extra method, event.progress(msg).
EnvironmentZYGO_REQUEST_ID; ZYGO_DEADLINE_MS, the request’s time budget in milliseconds; ZYGO_WORKSPACE if a workspace was sent; ZYGO_FUNCTION, the function’s name; plus the function’s env. ZYGO_TENANT holds the same name as ZYGO_FUNCTION; it is an older, misleading name kept so that old handlers do not break.
SecretsFiles at /run/secrets/<NAME>, mode 0400, readable only inside this function’s sandbox, and gone once no request of it is running. Never in the environment. In a runtime pool: the calling tenant’s values, and the request has its zygote to itself while they exist.
FilesThe image, read-only; your mounts; a temporary folder of its own, which TMPDIR names and which is removed afterwards; /venv if there are requirements. /tmp itself is shared by every request in the sandbox: write through tempfile, os.tmpdir() or $TMPDIR, not to a literal /tmp/... path.
Working folderworkdir (/app), or the workspace if one was sent: the agent changes into it before your code runs.
MemoryA copy of the zygote’s. What you change is yours alone and gone at the end.

What a request returns

A return value must turn into JSON; in Python, bytes become base64. Output printed to stdout and stderr is not the result. Each is collected separately (the last 256 KiB of each) and returned beside it, and sent live, in pieces, when the caller asks for a stream. If the handler raises an exception, the request fails with exit code 1, and its error is the traceback without the agent’s own lines. event.progress("step 2 of 5") sends a progress line to a caller that is streaming, and does nothing otherwise.

                   ┌──────────── what comes back ────────────┐
  handler(event) ─▶│ result     the return value, as JSON    │
                   │ stdout     what it printed (≤ 256 KiB)  │
  print(...)    ──▶│ stderr     what it logged  (≤ 256 KiB)  │
                   │ exit_code  0, or 1 if it raised         │
                   │ error      the traceback, if it raised  │
                   └─────────────────────────────────────────┘

mode = "stdin"

This is for a Python handler written as a script rather than as a function. The file is run once per request with the event as JSON on standard input, and whatever it prints to standard output is parsed as the result. A non-zero exit is an error. Use it for existing scripts you do not want to change. It is slower than a handler: each request starts a new Python process for the script, inside the warm sandbox, so the script’s imports are paid every time. Because it starts a program, it cannot run under seccomp = "strict", which forbids that.

[fn.legacy]
entry = "./old_script.py"
mode  = "stdin"

Warm-exec functions

For a program that starts fast, set cmd and no entry. The sandbox is kept warm, and each request starts cmd as a new process inside it. The contract is the simplest one possible: read one JSON event from stdin, write one JSON result to stdout, exit 0. Stderr is kept as the log, and a non-zero exit is a failure. sh -c cat is the smallest program that obeys it. It works with any language and any image, and needs no agent. It costs a median of 1.4 ms per request, measured on a Lima VM, plus your program’s own start.

[fn.parse]
image  = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"]   # a static binary you built
cmd    = ["/app/parse"]

Runtime pools

A pool is a warm interpreter with no code of anyone’s in it. Each request carries a script — as source, or as the sha256: digest (a fingerprint of the file’s bytes) of a script stored earlier with PUT /scripts. The forked child loads it, runs its handler (or the function named by entry_point), and exits. The script is loaded after the child’s seccomp filter is on, so even its import-time code is filtered, and a pool’s seccomp profile defaults to strict. strict has no socket, so a pool whose scripts call out names seccomp = "default" beside its network; asking for a network under strict is refused rather than left silently unusable. This is how a platform with ten thousand user scripts keeps a handful of zygotes warm instead of ten thousand.

zygo serve --runtime py312 --image python:3.12-slim --agent python
zygo serve --runtime node22 --image node:22-slim --agent node --min-warm 2 --max-warm 8
zygo exec --runtime py312 --script report.py '{"month": "2026-09"}'
zygo exec --runtime py312 --script sha256:9f2c… --entry-point monthly '{}'
  one pool, many tenants' scripts
  ┌────────── pool py312 (min_warm 2, max_warm 8) ──────────────┐
  │  zygote  python + requirements, NO user code                │
  │    ├── fork ─▶ child loads script A (tenant acme) ─▶ exit   │
  │    ├── fork ─▶ child loads script B (tenant beta) ─▶ exit   │
  │    └── fork ─▶ child loads script A again         ─▶ exit   │
  └─────────────────────────────────────────────────────────────┘
      +0.5 ms per request, against a function with its code warmed in

A pool keeps min_warm zygotes ready whatever the load (default 1; 0 counts as 1) and may grow to max_warm under load (default the larger of min_warm and 4). --agent is python, node, or the path of your own agent inside the sandbox. The +0.5 ms was measured with a different script on every request, a thousand of them: +0.47 ms on the Lima VM and +0.46 ms on Docker Desktop’s (chapter 25). A pool with a cmd instead of an agent is a warm-exec pool: the script’s path is passed as the last argument of cmd on each request.

No listener in a pool. A pool’s requests belong to different tenants and share one network namespace, so a script that opened a TCP port on loopback would be offering a channel to every other request in the pool. Two layers refuse it: strict, the pool default, removes the socket calls, and under it Landlock refuses bind in a shared namespace (kernel 6.7+). A function has no such rule: its namespace is one tenant’s, and it may listen on its own loopback (chapter 14).

Secrets in a pool. A pool can name secrets (secrets = ["STRIPE_KEY"] in [runtime.<name>], or serve_runtime(..., secrets=[...])), but it holds no values: the zygotes are shared. On each request the calling tenant’s values are read from the tenant secret store and written as /run/secrets/<NAME> for that one request, exactly as for a function — and while they exist, the request has its zygote to itself, so no other tenant’s child is forked beside the files. A tenant that lacks one of the names is refused before anything runs. Chapter 14 has the rules; zygo stop <name> stops a pool as it does a function.

A multi-tenant consumer on the warm path

Think of a low-code platform, a workflow engine or a plugin host. It has hundreds of scripts written by its users, the tenants, and a script changes whenever somebody presses Save. Each project has its own mounts and its own egress allowlist (the list of hosts it may reach), and each run has its own secrets. The one-shot mapping is one sandbox per event. The warm mapping is one warm zygote per script version, forked per run, and it looks like this:

import hashlib
import zygo_sdk as zygo

client = zygo.connect()                      # zygo api --allow-deploy, in the VM on a Mac

def run(project, script_source, event, secrets):
    # A version is a function. The name carries the digest, so an edit is a
    # new function and the old one is reaped by --idle-timeout, not by you.
    digest = hashlib.sha256(script_source.encode()).hexdigest()[:16]
    name = f"{project.id}-{digest}"
    client.serve(
        name,
        {
            "entry": project.script_path(digest),          # the version, on disk
            "mounts": [f"{project.data_dir}:/data:rw"],    # per project
            "network": "egress",
            "allow": project.allowlist,                    # per project
            "secrets": list(secrets),                      # names; values per run
            "idle_timeout": "10m",                         # reaped when idle
            "cold_after": "1h",
            "mem": "256M", "timeout": "30s",
        },
        if_changed=True,                                   # a no-op when it is warm
    )
    return client.fn(name)(event)                          # one fork
  project acme, script v1 ─▶ function "acme-3f9a…" ─▶ warm zygote ─▶ fork per run
  project acme, script v2 ─▶ function "acme-c21e…" ─▶ warm zygote ─▶ fork per run
                             (v1 is no longer called: paused after 10m, dropped after 1h)
  project beta, script v1 ─▶ function "beta-77d0…" ─▶ warm zygote ─▶ fork per run

What each line buys

  • entry and the mounts are paths on the host Zygo runs on. A function’s code is named by path, never sent, so this shape is for a worker that shares a disk with Zygo. A caller on another machine sends each script with its request instead, to a runtime pool (chapter 17).
  • if_changed=True makes the serve free when the version is already warm. The caller does not have to track state: it always asks, and the supervisor does nothing when nothing changed.
  • Mounts and the allowlist are per function, so two projects’ versions are two zygotes that share an interpreter’s memory pages and nothing else.
  • Secrets are per run. The names are declared once; the values arrive with the request and exist as files only while the request runs.
  • idle_timeout and cold_after are the eviction policy. A version nobody has called for ten minutes is paused (still in memory, one write to wake). After an hour it is dropped, and the next call pays a warm-up — about 150 ms for a Python handler, plus its imports.

Four hundred projects do not mean four hundred warm zygotes. They mean as many as were called in the last ten minutes, which is the number that matters, and zygo ps shows it. ADR 0005 has the memory per warm script, measured on a 4 GB VM, and the eviction policy written down. examples/workflow-engine/ is this shape end to end — a worker draining a job queue, one warm function per script version, and an LRU (least recently used) list over warm scripts — in Python and Node.

Other languages

There are two ways, and you pick by whether starting your runtime is slow enough to be worth doing only once.

Warm-exec is the simple one: give a function a cmd and no runtime. Each request is a fresh process in the held sandbox, with the event on standard input and JSON expected on standard output. That costs about 1.4 ms and needs no code from you beyond the program. Go, Rust, C and bash all belong here; examples/warm-exec/ has Go and shell examples.

An agent is for a runtime that is slow to start. It warms once and gives each request a process of its own. Two agents ship with Zygo, and the runtime is chosen from the entry file’s extension:

entry ends inruntimeHow each request runs
.pypythona fork of the warmed interpreter
.js, .mjs, .cjs, .tsnodeone of the pre-loaded, parked workers
.gogonot built yet: the name is accepted, but no Go agent ships, so serve fails with “no warm agent for go”. Use warm-exec for Go.
anything{ agent = "/path" }your own agent, at that path in the sandbox
[fn.resize]
entry = "./resize.py"      # runtime = "python": a fork of the warmed interpreter
[fn.summarise]
entry = "./summarise.js"   # runtime = "node": a pool of pre-loaded workers
[fn.custom]
entry   = "./handler.rb"
runtime = { agent = "/app/zygo-agent" }

Node has no fork() in the Unix sense, so its agent keeps workers loaded and parked instead — one request each, replaced off the request path. Everything above that is the same: the same wire protocol, the same per-request cgroup, the same deadline, the same secrets. Deno and Bun have no agent and are not waiting for one (ADR 0003); they start in a few milliseconds, so a warm-exec pool serves them.

Your own agent

Any language can have a real warm path by shipping an agent: a program that talks to the supervisor over a socket, using a small documented protocol (spec/protocol.md). Messages are length-prefixed JSON: READY when warm, EXEC per request, FORKED and GO around the fork, CHUNK for streamed output, RESULT, DONE, CANCEL, PING/PONG and SHUTDOWN. Name it with runtime = { agent = "/path/in/sandbox" }. zygo agent test runs the conformance suite against it before you trust it:

zygo agent test /bin/sh -- examples/agents/sh/agent.sh examples/agents/sh/handler.sh

examples/agents/ has a complete agent in POSIX sh of about 130 lines that passes the same checks the shipped ones do. Chapter 18 is the full guide.

Handler rules, in short

  1. Do slow things at module level; they run once.
  2. Do not start threads at import.
  3. Create random generators inside the handler, not at import.
  4. Return JSON; print only for logs.
  5. Read secrets from /run/secrets/, never from the environment.
  6. Write only to /tmp, the workspace or a :rw mount; the rest is read-only.
  7. Assume nothing survives the request — because nothing does.

14. Limits, networking and secrets

A sandbox is only as good as the walls you give it. This chapter shows how you set those walls in sandbox.toml: how much a function may use, what it may reach on the network, and how a password or API key gets to it without leaking. Every wall has a safe value even if you set nothing.

The spec file in one minute

A spec file is sandbox.toml: one file that describes every function in a project. [defaults] holds values for all functions, and each [fn.<name>] section describes one function and may change any of them. Chapter 20 lists every field; this chapter explains the ones that set limits, the network and secrets.

[defaults]
image     = "python:3.12-slim"
isolation = "ns"          # ns | gvisor | vm
mem       = "256M"
cpu       = 1.0
pids      = 64
timeout   = "30s"
network   = "none"

[fn.resize]
entry        = "./resize.py"       # defines handler(event)
requirements = "./requirements.txt"
system       = ["libwebp7"]        # apt packages, installed once as a layer
mem          = "512M"
mounts       = ["./cache:/cache:rw"]

[fn.parse]
image  = "alpine:3"                # no runtime → warm-exec
mounts = ["./bin/parse:/app/parse:ro"]
cmd    = ["/app/parse"]

[fn.fetch]
entry       = "./fetch.py"
network     = "egress"             # nothing else is reachable
allow       = ["api.stripe.com:443", "*.example.com:443", "203.0.113.0/24:5432"]
connections = 32
bandwidth   = "2M"
secrets     = ["STRIPE_KEY"]

Which value wins

A value can come from four places. The highest one that sets a field wins.

  highest ┌───────────────────────────────┐
          │ a CLI flag (or an API call)   │   --mem 512M
          ├───────────────────────────────┤
          │ [fn.<name>]                   │   mem = "512M"
          ├───────────────────────────────┤
          │ [defaults]                    │   mem = "256M"
          ├───────────────────────────────┤
  lowest  │ Zygo's built-in default       │   256M
          └───────────────────────────────┘

Lists and tables — allow, mounts, env, secrets, system — replace the one below them. They are never added together. So an allow list in [fn.fetch] is the whole list for that function, and the one in [defaults] no longer counts. Two commands show you the result:

zygo spec explain resize     # every field of `resize`, after the merge
zygo spec validate           # check the whole file, run nothing

Every limit has a value

A limit is a ceiling the kernel enforces, most of them through the function’s cgroup (chapter 3). Every limit has a default, and you cannot turn one off by leaving it out. The only way to remove one is a flag named --allow-unlimited. This is on purpose: a sandbox with no memory limit is not a sandbox, and people usually end up with one by forgetting, not by deciding.

FieldWhat it boundsDefaultWhat happens when it is hit
memmemory256Mthe kernel kills the request; exit 137, oom_killed
cpuCPU time, in cores1.0the request is slowed down, not killed
pidsprocesses and threads64the next fork fails
timeoutwall-clock time30sthe whole request is killed; exit 137, timed_out
scratchthe writable /tmpthe smaller of 64M and half of memwrites fail: “No space left on device”
nofileopen files1024the next open fails: “Too many open files”
connectionsTCP connections at once256the next connection is refused
bandwidthbytes a second sentunlimited, with a warningtraffic is slowed down
io_read, io_writedisk bytes a secondunlimited, with a warningdisk reads or writes are slowed down

The sections below take them one at a time.

Memory: mem

mem is the most memory the request may use, counted by the kernel for every process it starts. At 90% of it the kernel starts to push back and reclaim memory; at 100% it kills the request. There is no swap, so memory cannot quietly spill to disk and slow the host down. The smallest allowed value is 8M. The whole process tree dies together, and the warm zygote and the other requests keep running.

  0 ─────────────────────────────── 90% ───────────── 100%  (mem)
  normal use                        memory.high:      memory.max:
                                    kernel reclaims,  request killed,
                                    request slows     exit 137, oom_killed

CPU: cpu

cpu is how many CPU cores the request may use, as a number: 0.5 is half a core, 2 is two. The kernel checks it every 100 ms. A request that tries to use more is simply made to wait for the rest of that period. So a busy loop slows only itself, never the host, and it is not killed for it. This is also why a function driven past its quota shows a slow tail of tens of milliseconds; chapter 25 explains that number.

Processes: pids

pids is how many processes and threads the request may have at once. It is the defence against a fork bomb: a program that copies itself again and again until the machine can do nothing else. When the limit is reached, the next fork or new thread simply fails, and the program gets an error. 0 is not allowed.

Time: timeout

timeout is the wall-clock time a request may run, from start to end. When it runs out, Zygo kills the request through its cgroup, so every process the handler started dies with it, not just the one Zygo can see. The request exits with code 137, and the outcome says timed_out. timeout = 0 means “no limit” and needs --allow-unlimited; zygo up never accepts it.

Scratch space: scratch

A sandbox’s root file system is read-only. The one place it may write is /tmp, which lives in memory, and scratch is its size. Because it is memory, it counts against mem, so scratch must be smaller than mem, and Zygo warns you when it is more than half. The default is the smaller of 64M and half of mem. scratch is also the biggest single file a process may write, and /tmp holds 10 000 files at most. When it is full, writes fail with “No space left on device”.

Open files, connections, bandwidth and disk

nofile (default 1024) is how many files, sockets and pipes one process may have open; past it, open fails with “Too many open files”. connections (default 256) is how many TCP connections a networked function may hold at once; the firewall inside the sandbox answers the next one with a reset. It cannot be 0. bandwidth is how many bytes a second the function may send, and on hosts with an ifb device also receive. io_read and io_write limit disk reads and writes a second. These three have no default, so Zygo warns you when a served function leaves them unset.

Thread pools follow cpu

Many number-crunching libraries — OpenBLAS, OpenMP, MKL, numexpr, polars, Rayon — start one thread per CPU on the machine. In a sandbox with cpu = 2 on a big host, those threads then fight over two cores. On a 5-CPU host, eight numpy requests at once under cpu = 2 ran 24–38 a second with the host’s count and 155–173 a second with one thread each. So Zygo sets these variables to cpu, rounded up:

OMP_NUM_THREADS   OPENBLAS_NUM_THREADS   MKL_NUM_THREADS      NUMEXPR_NUM_THREADS
VECLIB_MAXIMUM_THREADS   RAYON_NUM_THREADS   POLARS_MAX_THREADS
PYTHON_CPU_COUNT   GOMAXPROCS

If the image or the spec sets any of them, that value wins.

Knowing which limit ended a request

A request that Zygo or the kernel stopped exits with 137, both for time and for memory, because both are a SIGKILL. When you need to know which, Zygo tells you: zygo run --outcome FILE writes timed_out, oom_killed, peak memory and wall time to a file, and a warm request reports the same fields. Chapter 12 shows the one-shot case in full.

Networking: four modes

A sandbox starts with no network at all. network opens it, one step at a time:

network = "none"     # the default
ModeWhat the sandbox can reach
nonenothing: an empty network namespace with only loopback. A function may talk to itself over TCP there
egressexactly what allow names, plus DNS for those names
fullthe public internet (bridge, Docker’s word, is accepted and printed back as full)
hosteverything the host can reach; no network namespace. Needs --allow-host-net

Egress means traffic going out. No mode lets anything connect in to a sandbox: Zygo never accepts connections for your code.

How egress works

egress and full give the sandbox’s network namespace to pasta. pasta is a small program that moves the sandbox’s packets out through the host’s normal sockets, running as your own user. Inside the namespace, Zygo installs an nftables firewall — the kernel’s packet filter — that lets only allowed traffic out. No root is needed anywhere. Under egress, Zygo also runs its own tiny DNS resolver inside the sandbox, and that resolver is what fills the firewall.

  ┌─ sandbox network namespace ─────────────────────────────────────────┐
  │                                                                     │
  │  handler                                                            │
  │    │ 1. "where is api.stripe.com?"                                  │
  │    ▼                                                                │
  │  ┌──────────────────────┐  2. on the allow list?                    │
  │  │ Zygo's DNS resolver  │     no  → "no such name" (NXDOMAIN)       │
  │  │ (on loopback)        │     yes → look it up, then …              │
  │  └──────────┬───────────┘                                           │
  │             │ 3. … add its addresses to the firewall FIRST,         │
  │             ▼    then answer the handler                            │
  │  ┌──────────────────────────────────────────────────────┐           │
  │  │ nftables firewall                                    │           │
  │  │  loopback, replies to allowed connections   → pass   │           │
  │  │  more than `connections` open               → reset  │           │
  │  │  10.x, 192.168.x, 169.254.169.254 …         → reject │           │
  │  │  addresses the resolver added, on that port → pass   │           │
  │  │  anything else                              → reject │           │
  │  └──────────┬───────────────────────────────────────────┘           │
  │             │ 4. connect to api.stripe.com:443                      │
  │             │ 5. out through tap0, the sandbox's network card       │
  └─────────────┼───────────────────────────────────────────────────────┘
                ▼
         ┌──────────────┐
         │ pasta        │──▶ normal sockets on the host ──▶ the internet
         │ (your user)  │
         └──────────────┘

Because the answer only comes back after the firewall is updated, a wildcard is checked against the name the handler really asked for. A service that changes its address keeps working, because each lookup adds the new one. The handler cannot use a resolver of its own to get around the list: under egress the only resolver it can reach is Zygo’s. The host’s search domains never enter the sandbox. A blocked connection is rejected at once, not silently dropped, so a program fails fast instead of waiting for a timeout.

A network needs a profile that can open a socket

seccomp = "strict" removes socket and connect (chapter 24). Under it an egress or full sandbox has a namespace, a firewall and an allowlist, and every connection still fails with EPERM before any of them is asked. So the combination is refused when the sandbox is declared, with the way out in the message. A runtime pool is strict unless it says otherwise, so a pool that calls out names seccomp = "default" next to its network.

The allowlist

allow is the list of places an egress function may reach. It is only valid with network = "egress". An egress function with an empty list can reach nothing, and Zygo warns you.

allow = ["api.stripe.com:443", "*.example.com:443", "203.0.113.0/24:5432"]
FormExampleMatches
host:portapi.stripe.com:443that name, that port
hostapi.stripe.comthat name, every port
*.domain:port*.example.com:443every name under example.com, but not example.com itself
CIDR:port203.0.113.0/24:5432that range of addresses, that port
IPv6[2001:db8::1]:443, 2001:db8::/32use brackets when a port follows

A CIDR is a way to write a range of addresses: 203.0.113.0/24 means every address that starts with 203.0.113..

Private ranges and the cloud metadata address

Private addresses are the ones used inside a home or company network, such as 10.x.x.x or 192.168.x.x. Link-local ones include 169.254.169.254, where cloud servers answer questions like “what are my credentials?”. That address is the first thing a compromised handler tries. So in every mode with a namespace — none, egress and full — these ranges stay closed:

10.0.0.0/8   172.16.0.0/12   192.168.0.0/16   127.0.0.0/8   169.254.0.0/16
100.64.0.0/10   0.0.0.0/8   224.0.0.0/4 (multicast)   240.0.0.0/4 (reserved)
::1   ::   fe80::/10   fc00::/7   ff00::/8 (multicast)

Multicast is on the list because a multicast group is a way to talk to the neighbours without naming any of them.

Only --allow-private-net opens them. An allow rule inside one of these ranges — a CIDR, or a single address such as 192.168.1.70:8765 — is refused unless you pass that flag. Before 0.1.4 a single address was read as a host name: serve accepted it, and the firewall then refused every connection to it as the private address it is, with nothing to say why.

Over the HTTP API a request body can never set it. Whoever starts the API can: zygo api --allow-deploy --allow-private-net lets what deploy callers serve name private addresses in allow (chapter 17).

The host’s own loopback

127.0.0.0/8 is on that list, but the sandbox has a loopback of its own, and the firewall lets it through: Zygo’s resolver lives there. So the question is whether the sandbox’s 127.0.0.1 can lead to the host’s. pasta can do exactly that, and does by default: it forwards a connection to the sandbox’s loopback on to the same port on the host’s loopback, and it answers for the gateway address itself and hands those connections to the host too. Zygo turns both off (--tcp-ns none --udp-ns none --no-map-gw). A Postgres, a Redis or zygo api bound to 127.0.0.1 on the host is out of reach from a sandbox, whatever the allowlist says and whatever the kernel’s Landlock can or cannot do.

  sandbox                              host
  ┌────────────────────────┐           ┌──────────────────────────────┐
  │ connect 127.0.0.1:5432 │──▶ own    │ 127.0.0.1:5432  postgres     │ ◀── not reachable
  │                        │  loopback │ 127.0.0.1:7700  zygo api     │ ◀── not reachable
  │ connect <gateway>:5432 │──▶ the    │                              │
  │                        │  router   │ 10.0.0.5:5432   postgres     │ ◀── allow 10.0.0.5:5432,
  └────────────────────────┘           └──────────────────────────────┘     --allow-private-net

A function that must reach a service on its own host is given that service on a second address the host has — a Docker bridge’s 172.17.0.1, or one on a dummy interface — with an allow rule naming it and --allow-private-net typed by a person. Not the host’s main address: pasta gives that same address to the sandbox’s own network card, so a sandbox dialling it reaches itself and is refused. A service on loopback only stays loopback only. Before 0.1.4 this was not so: an allow rule for any name on port 5432 also opened the host’s own 127.0.0.1:5432, and on a kernel without Landlock’s network rules (below 6.7) every port the host had bound on loopback was open.

Listening inside a sandbox

A function, and a zygo run sandbox, may open a TCP listener on its own loopback. Nothing from outside reaches it: pasta forwards no port in, and under none there is no interface but loopback. So a program that talks to itself over 127.0.0.1 — a Jupyter kernel, a Ray or Dask worker, a test suite with a local server, n8n’s runner with its health-check port — works in a sandbox the way it works on a laptop.

A runtime pool may not. Its requests belong to different tenants and share one network namespace, so a listener on loopback there would be a channel from one tenant’s request to another’s. strict, the pool default, removes the socket calls; under it, Landlock refuses bind in a shared namespace on kernels from 6.7 (chapter 13).

  function (one tenant's namespace)        runtime pool (shared between tenants)
  ┌──────────────────────────────┐         ┌──────────────────────────────┐
  │ listen 127.0.0.1:8888  ✓     │         │ listen 127.0.0.1:8888  ✗     │
  │ connect to it          ✓     │         │   strict: no socket at all   │
  │ from outside: nothing gets in│         │   Landlock: bind refused     │
  └──────────────────────────────┘         └──────────────────────────────┘

One corner under egress: the connect half of talking to yourself is subject to the allowlist’s port rule, which applies to loopback too. A function that listens on 5681 and connects to it names the port — allow = ["127.0.0.1:5681"], which needs --allow-private-net — or runs under none or full, where no port rule stands in the way. Until 0.1.3 no sandbox could listen on TCP at all on a kernel from 6.7, and a sealed one could not connect to itself either; ADR 0008 records why that changed.

If pasta or nft is missing

A networked sandbox needs both pasta and nft on the host. If either is missing, the sandbox does not start. Zygo never falls back to starting it with an open network. zygo doctor tells you what is missing, and chapter 22 covers the common problems.

Secrets: why not environment variables

A secret is a value that must not leak: an API key, a database password, a token. The usual way to pass one is an environment variable, but that is a poor fit for a sandbox. The zygote would hold it for its whole life, every forked request would inherit it, and a handler that prints its environment would print it. Zygo takes a different path: a secret is a file, and it exists only while one request runs.

Declaring a secret

You name the secret in the spec, and give its value in the shell that runs zygo serve or zygo up:

[fn.fetch]
secrets = ["STRIPE_KEY"]
export STRIPE_KEY=sk_live_...
zygo up

The value is read from your shell, not from the supervisor’s environment. If a name has no value, the command refuses and says which name is missing. The handler reads the secret as a file:

def handler(event):
    key = open("/run/secrets/STRIPE_KEY").read().strip()

A secret lives for one request

For each request, the supervisor writes the file /run/secrets/<NAME> from outside the sandbox. It is created with mode 0400 — readable only by its owner — from the first moment, not changed to that mode afterwards, so there is no instant when anyone else could read it. Inside the sandbox, only this function’s requests can read it — this one, and any other request of the same function running at the same moment, which has the same value anyway. The file is removed when the last of them ends.

  supervisor (holds the value)
      │
      │ request 7 arrives
      ├──▶ write /run/secrets/STRIPE_KEY  (0400, this function's requests only)
      │        │
      │        ▼
      │    ┌─────────────────────────────┐
      │    │ request 7: handler(event)   │  reads the file, uses the key
      │    └─────────────────────────────┘
      │        │ request 7 ends
      ├──▶ file removed
      │
      │ request 8 arrives ──▶ written again, removed again

  never in: the environment · the zygote's memory · the control socket

A request that is taken over can read its own function’s secret while it runs, and never a secret that another function uses. This matters most for AI agent tools: a model that writes the code cannot print a secret it never had in its environment.

env or secrets?

envsecrets
Where it appearsenvironment variablesthe file /run/secrets/<NAME>
Who can see itthe zygote and every requestone request, while it runs
Where the value comes fromthe spec fileyour shell, or the tenant store
Use it forsettings: LOG_LEVEL, a regionkeys, tokens, passwords

A name cannot be in both lists. Never put a secret in env.

The tenant secret store

Reading secrets from a shell works for one person at a terminal. It does not work for a platform whose customers each bring their own keys. A tenant is one such customer, with its own functions and tokens (chapter 17). For them Zygo keeps an encrypted secret store, one set of secrets per tenant, saved on disk under Zygo’s data folder.

zygo secrets keygen                              # print a new key (32 bytes, hex)
export ZYGO_SECRETS_KEY=...                      # before the supervisor starts
zygo secrets set acme STRIPE_KEY                 # prompts, echo off
echo "$KEY" | zygo secrets set acme STRIPE_KEY --stdin
zygo secrets ls acme                             # names only, never values
zygo secrets rm acme STRIPE_KEY

Over the HTTP API the same is PUT /tenants/<id>/secrets/<name>. Functions you serve from the CLI belong to the tenant named default.

How the store fills in values

When a function is served, Zygo first takes the values the client sent — for the CLI, from your shell. Then it fills in, from the tenant’s store, any name the client did not send. The client wins where both have a value. Only the names the spec lists are delivered; anything extra is dropped.

  spec: secrets = ["STRIPE_KEY", "DB_PASSWORD"]

  sent by the client:   STRIPE_KEY=sk_test_…   ─┐
                                                ├─▶  STRIPE_KEY  = sk_test_…  (client wins)
  tenant store (acme):  STRIPE_KEY=sk_live_…   ─┤    DB_PASSWORD = …          (from store)
                        DB_PASSWORD=…          ─┘
  a name with no value anywhere → the serve is refused, naming it

Secrets in a runtime pool

A runtime pool (chapter 13) is shared by every tenant, so it holds no secret values. It names them instead:

[runtime.py312]
image   = "python:3.12-slim"
agent   = "python"
secrets = ["STRIPE_KEY"]      # names a request may receive; no values here

Each request is given the calling tenant’s values — the tenant in the token or the X-Zygo-Tenant header; the default tenant for zygo exec — read from the store at that moment. Only the store: a pool never reads your shell, and a pool that names secrets on a host with no store key is refused when it is served, not on its first request. The files are written from outside, exist for that one request, and are removed when it ends.

  tenant acme calls py312             tenant beta calls py312
      │                                    │
      ├──▶ store: acme's STRIPE_KEY        ├──▶ store: beta's STRIPE_KEY
      ├──▶ /run/secrets/STRIPE_KEY         ├──▶ /run/secrets/STRIPE_KEY
      │    on a zygote acme has alone      │    on a different zygote
      └──▶ removed when acme's request ends

Two rules follow from “one directory per sandbox”:

  • A request with secrets has its zygote to itself while the files exist. It takes an idle zygote; if every zygote is busy, the pool grows by one (up to max_warm), and at max_warm the caller gets busy, which the SDKs retry. Ordinary requests skip a zygote that is taken this way. Requests without secrets share zygotes as before.
  • A missing name fails the request before anything runs. A tenant that has no secret under one of the pool’s names gets 400 (bad_spec) naming the secret and the route that stores it, never a value.

The store’s key

The key comes from ZYGO_SECRETS_KEY, or from a file named by ZYGO_SECRETS_KEY_FILE. It must be 32 bytes, written as hex or base64. A passphrase is refused: turning a password into a key safely needs extra machinery, and a store that quietly accepted hunter2 would be worse than one that says no. Zygo never saves the key. If you lose it, every stored secret is unreadable for good. Without a key, the supervisor still serves every function with no stored secrets; only the store refuses, and says which variable to set.

What the store protects, and what it does not

Each value is encrypted with ChaCha20-Poly1305, a modern cipher, with the tenant and the name bound to it. So a value cannot be moved to another tenant or another name by renaming files. A value may be at most 64 KiB; anything bigger is a file, not a secret. Values cannot be read back, only listed by name. The encryption protects the bytes on disk. It does not protect them from a process that can read the supervisor’s memory, and it is not a hardware security module; if you need that, use your cloud’s key service.

Loosening, by name

Three things are refused unless you type a flag that says exactly what it does:

FlagWhat it allows
--allow-host-netnetwork = "host"
--allow-private-netprivate and link-local addresses, and allow rules inside them
--allow-unlimitedtimeout = 0

The flags exist on zygo run, zygo serve and zygo mcp. zygo up has none of them. A spec that needs one has to be served on purpose, one function at a time, with the flag typed by a person.

15. Images and dependencies

A sandbox needs files to run: an interpreter, libraries, and your own packages. This chapter explains where those files come from — images, which Zygo pulls from normal registries, and the layers it builds on top of them — and how to keep the store tidy. There is no Dockerfile and no build step for you to run.

The big picture

A function’s file system is an image from a registry, plus up to three layers Zygo builds for you. The image is never changed. Each extra layer is built once and then shared.

   python:3.12-slim            from the registry; never changed
          │
          ├── + bytecode layer   .pyc files for the standard library     built on first pull
          ├── + system layer     apt packages: system = ["libwebp7"]     key: image + list
          └── + /venv            pip packages: requirements = "…"        key: image + file
                     │
                     ▼
   what the function sees as /   (read-only, except /tmp)
   shared by every function that names the same image and the same lists

Images, layers, tags and digests

An OCI image is the standard package format for containers, the same one Docker uses. It is a stack of layers: each layer is an archive of files, and stacking them gives one file tree. A registry is a server that stores images, such as Docker Hub or ghcr.io. A tag is a friendly name, like python:3.12-slim, that the owner can move to a new version at any time. A digest is the image’s fingerprint, like sha256:4f1b…, and it never changes: the same digest always means exactly the same bytes.

Pulling an image

To pull is to download an image into Zygo’s local store. Any OCI image from any registry works.

zygo pull python:3.12-slim                 # into the local store
zygo pull --platform linux/amd64 alpine:3  # another CPU type than this host's
zygo images                                # reference, digest, layers, size, when pulled
zygo images --json                         # the same, for a program: match `reference` exactly

--platform takes os/arch. Without it, Zygo pulls the version built for the host it runs on.

When Zygo pulls for you

zygo run pulls an image the first time you use it, like docker run. Two flags change that. --pull never refuses a missing image instead; the run exits 1, and --outcome records phase: plan. --pull always pulls again, for a tag that may have moved. zygo serve and zygo up never pull. A deploy should not quietly depend on a registry being up, so they stop and ask you to pull first.

CommandImage missingImage present
zygo runpulls ituses it
zygo run --pull neverrefuses, exit 1uses it
zygo run --pull alwayspulls itpulls it again
zygo serve, zygo uprefuses: “pull it first”uses it

The store

Zygo unpacks each layer once, into a content-addressed store: every layer is saved under its own digest, so the same layer is never stored twice, even when many images share it. A sandbox does not get a copy. The layers are mounted read-only into it, stacked with overlayfs (chapter 4). The store lives in Zygo’s data folder, ~/.local/share/zygo by default (chapter 21 says how to move it).

  store (on disk, once)                     sandboxes (no copies)
  ─────────────────────                     ─────────────────────
  layers/sha256:aa… ─┐
  layers/sha256:bb… ─┼── read-only mounts ─▶ sandbox 1   sandbox 2   sandbox 3
  layers/sha256:cc… ─┘                       (each stacks the same three layers)
  + the compressed download of each layer, kept beside it

Private registries: zygo login

A private registry needs a user name and a password or token. zygo login stores them:

zygo login ghcr.io -u you                                   # prompts, echo off
echo "$TOKEN" | zygo login ghcr.io -u you --password-stdin  # for CI

There is no --password flag, on purpose. A command-line argument is visible to every process on the machine through ps, and it lands in your shell history. Zygo checks the login against the registry before it saves it, so a typo fails now, not at the next deploy. The credential goes into Zygo’s own auth.json in its data folder, readable only by you (mode 0600).

Docker’s logins are read too

If you already logged in with Docker, Zygo can use that. It reads ~/.docker/config.json, or the folder named by $DOCKER_CONFIG, and never writes to it. When both have a login for the same registry, Zygo’s own auth.json wins.

  a pull from ghcr.io needs a login
      │
      ├─ 1. <data>/auth.json          (written by `zygo login`)    ← wins
      └─ 2. ~/.docker/config.json     (or $DOCKER_CONFIG; read only)

Dependencies without touching the image

What you would write in a Dockerfile goes in sandbox.toml instead. There are two kinds of dependency, and neither changes the image:

requirements = "./requirements.txt"   # Python packages, into a venv
system       = ["libwebp7"]           # apt packages, into a layer

Python packages: requirements

A venv (virtual environment) is a folder that holds a set of Python packages apart from the system’s. Zygo builds one from your requirements.txt, inside a sandbox, with the image’s own pip. That is the only way to be sure the packages match the Python that will run them. The venv is mounted read-only at /venv, with /venv/bin first on PATH. On the Lima VM, the first build of requests took about 2.5 seconds, and every later run found the built venv in a millisecond or two (chapter 25 has the numbers and the machine).

System packages: system

Some Python packages need a C library from the operating system, such as libwebp7 for WebP images. system lists Debian (apt) packages, optionally with a version: "libpq5=16.4-1". Zygo installs them inside a writable copy of the image, compares the result with the original, and saves the difference as a new OCI layer of its own. There is no Dockerfile, and nothing else is rebuilt.

  image layers (read-only) ──▶ writable copy ──▶ apt-get install libwebp7
                                                        │
                                  diff: only the files apt added or changed
                                                        ▼
                                          a new layer, "<image>+system.<key>"
                                          stacked on the image for this function

How the caches are keyed

Both kinds are built once and shared. Each is saved under a key: the image’s digest plus the list or the file’s bytes. Every function that names the same image and the same list uses the same build. Edit the list and you get a new key, so a new build. A different image is also a new key, because a package built for one Python often fails with a confusing error on another.

BuiltKeyed onShared by
the venvimage digest + the bytes of requirements.txtevery function and every run with that pair
the system layerimage digest + the system listevery function with that pair
the bytecode layerimage digesteverything that uses the image

One-shot runs share the same cache

zygo run can use a requirements file too:

zygo run --requirements ./requirements.txt python:3.12-slim python3 -m pytest

The first job with a given image and file builds the venv. Every job after that, and every warm function with the same pair, reuses it.

The Python bytecode layer

Python turns each .py file it imports into bytecode — a faster form it can run — and normally saves it as a .pyc file for next time. The official python:*-slim images ship no .pyc files at all: python:3.12-slim has 1097 .py files in its standard library and none compiled. A sandbox’s root is read-only, so Python cannot save them either. So every run compiled every module again; import re alone took 34 ms.

How the bytecode layer works

The first time Zygo pulls or runs such an image, it compiles the standard library once, inside a sandbox, into a layer of its own. For python:3.12-slim that took about 3.2 s and made 18.6 MB. The layer is stacked on the image, so the .pyc files sit next to the sources. They are marked unchecked: a layer never changes, so there is nothing to check them against. On the Lima VM used for testing, a script that imports ten common modules (re, json, urllib.request and more) went from 165 ms to 35 ms (chapter 25 has the table).

  without the layer                         with the layer
  ─────────────────                         ──────────────
  import re                                 import re
    └─ read re.py, compile it  (every run)    └─ read re.cpython-312.pyc  (ready)
    └─ cannot save .pyc: root is read-only

When there is no bytecode layer

An image that already has bytecode, or has no Python, is used as it is. If the build fails, Zygo prints a warning and uses the original image; a run never fails because of it. ZYGO_BYTECODE=0 turns the layer off.

Nix: not built

sandbox.toml accepts a nix field, so the file format is ready for it, but Zygo does not build Nix packages yet. Serving a function that sets nix fails with a clear error. Use system or requirements for now.

zygo.lock: the same image next time

A tag can move: python:3.12-slim next month is not the same bytes as today. zygo up writes zygo.lock next to the spec. It records the digest each image resolved to, the versions apt chose for each system package, and the hash of each requirements file. Commit it to version control.

  zygo up
     │
     ├─ spec edited for this function?      yes ─▶ serve it, rewrite its lock entry
     │                                       no
     ├─ image digest same as zygo.lock?     yes ─▶ serve it
     │                                       no
     └─ refuse this function, print both digests
          → zygo up --relock   accepts the new image

Editing the spec re-locks that function without asking, because you just asked for the change. Chapter 20 has the rules in the reference.

Removing an image: zygo image rm

zygo image rm python:3.11-slim

This removes the image, the system-package images derived from it, and then anything that only they kept alive. It is refused while a warm function uses the image, because that function’s root file system is those layers, mounted. Stop the function first.

Pruning the store

Pruning deletes what is no longer needed. Start with --dry-run, which only reports.

zygo image prune --dry-run
zygo image prune                          # only what nothing can reach any more
zygo image prune --unused-for 30d --blobs
CommandWhat it deletes
prunelayers of removed images; venvs, flattened roots and derived layers whose image is gone
--unused-for 30dalso venvs and flattened roots not used for 30 days, even if their image is still here
--blobsalso the compressed copy kept next to every unpacked layer
--dry-runnothing: it prints what it would delete

A flattened root is all of an image’s layers copied into one folder, which Zygo makes on kernels that cannot stack layers for a normal user. Every use of a cache is recorded, so a venv used yesterday is safe even at --unused-for 7d. --blobs roughly halves the store. The cost is a new download if an unpacked layer is ever lost.

16. Deploying and running in production

This chapter is about the day after the first demo: putting a project’s functions live, keeping them healthy, and changing them without dropping a request. It also covers running Zygo inside a container and on Kubernetes, choosing an isolation backend, and the things Zygo will not do for you.

From a spec file to warm functions

Deploying here means one command, zygo up. It reads sandbox.toml, asks the supervisor to warm every [fn.*] section, and writes zygo.lock. The supervisor is the long-lived Zygo process that keeps the warm functions (chapter 6).

  sandbox.toml ──▶ zygo up ──▶ supervisor ──┬──▶ fn.resize   ✓ warm
  + your shell's                            ├──▶ fn.parse    · unchanged
    secret values                           └──▶ fn.fetch    ✗ failed, reason printed
                     │
                     └──▶ zygo.lock  (image digests, apt versions; commit it)
zygo up        # every [fn.*] warm
zygo down      # stops what this spec declares, and nothing else

What zygo up prints

Functions come up in the order the file declares them, and each one gets a line of its own. ✓ means started or replaced, · means unchanged and left alone, and ✗ means it failed, with the reason. One failure does not stop the others: a file with ten functions where the sixth cannot start still brings up the other nine, and says which one failed. If any function failed, zygo up exits with 1. zygo up --json prints one document with every failure and its reason. The lines look like this (the numbers are only an example):

✓ resize — python, 38 MB, ready in 412 ms
· parse — unchanged (exec, 3 MB)
✗ fetch — fn.fetch.secrets: no value for STRIPE_KEY; set it in the environment …

Run it again: only what changed restarts

Running up after an edit is not a second deploy. For each function, Zygo compares four things with what is already running: the resolved spec, the secret values, the bytes of the handler file and the bytes of the requirements file. If all four are the same, the function is unchanged and left alone — warm memory, request counters and all. If it was paused or cold since the last deploy, up brings it back to warm. If anything differs, it is replaced.

Blue/green replacement

A changed function is replaced blue/green: the new version is started next to the old one, and traffic moves only when the new one is ready. Requests the old sandbox already accepted finish on it. Requests waiting in its queue go to the new one. No request is dropped, and none sees a half-started function.

  time ──────────────────────────────────────────────────────────▶

  old (blue)   ████████████████████████▓▓▓▓▓ finishes what it accepted, then stops
  new (green)            ░░░░░░ warming ░░░░████████████████████████████████
                                           ▲
                                  switch: the new one is warm;
                                  queued requests go to it

zygo.lock in a deploy

up records the image digest, apt versions and requirements hash for each function in zygo.lock. Commit it. If a tag has moved under a spec nobody edited, up refuses that function and prints both digests, so a deploy never quietly runs different code; zygo up --relock accepts the move. Chapter 15 explains it with a diagram. Remember too that up never pulls: pull the images first.

zygo up has no escape flags

--allow-host-net, --allow-private-net and --allow-unlimited do not exist on zygo up. A deploy from a file should never loosen a wall just because the file says so. A function that really needs one must be served by hand with zygo serve and the flag typed by a person (chapter 14). The one flag of the three on zygo api is --allow-private-net, for an embedder whose pools call back to a service of its own; it is typed by whoever starts the API, never sent in a request.

The supervisor

You rarely start the supervisor yourself. zygo serve, zygo up, zygo token, zygo secrets and zygo api start it when none is running, and give up if it is not ready within 10 seconds. It runs as your own user, never as root. When it gets SIGTERM — the normal “please stop” signal from systemd, Docker or Kubernetes — it stops taking new requests and gives the running ones up to 25 seconds to finish. That is less than the 30 seconds most process managers wait before they force a kill.

zygo supervisor status    # what it is, and where its socket lives
zygo supervisor stop      # drain, exit; the next serve or up starts a new one
zygo supervisor run       # run it in the foreground, to see why it will not start

zygo supervisor is a hidden command: it does not appear in zygo --help, because you seldom need it.

Running zygo api under systemd

On a Linux host the natural home for zygo api is a systemd unit: it starts at boot, restarts if it dies, and its logs go to the journal. One line in that unit matters more than the others. When any process inside a unit’s cgroup is killed by the kernel’s out-of-memory killer, systemd’s default (OOMPolicy=stop) stops the whole unit. A sandbox that goes over its mem limit is killed exactly that way, inside its own cgroup, which sits under the unit’s. So with the default, one request allocating too much memory takes down the API, the supervisor and every pool with it, and every later request is refused until somebody restarts the unit. OOMPolicy=continue tells systemd the kill was handled and the unit goes on.

  n8n-zygo-api.service            ◀── systemd watches memory.events here
  └─ zygo api
     └─ zygo.slice/
        └─ tenants/acme/resize/
           └─ req-0192            ◀── the kernel kills the hog here

A user unit, in ~/.config/systemd/user/zygo-api.service:

[Unit]
Description=Zygo API

[Service]
ExecStart=/usr/local/bin/zygo api --listen 127.0.0.1:7700
Environment=ZYGO_API_TOKEN=change-me
Restart=on-failure
# A sandbox over its memory limit is killed alone; the unit goes on.
OOMPolicy=continue
# The unit owns its cgroup subtree, which is where every sandbox goes.
Delegate=yes

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now zygo-api
loginctl enable-linger $USER          # keep it running when you log out

The same two settings on a transient unit, which is how a script or another service often starts it:

systemd-run --user --unit=zygo-api -p OOMPolicy=continue -p Delegate=yes -- zygo api

zygo doctor reports the unit’s policy as systemd OOM policy, and zygo api prints a warning at start when the unit it is in would stop. Chapter 22 has the journal lines this looks like when it has already happened.

Watching it

zygo ps                      # what is warm, and its counters
zygo top                     # ps on a timer, plus rates
zygo stats resize            # latencies over the log window
zygo logs resize -f          # the zygote's output and every request
zygo logs resize --failed -n 20

zygo stats keeps two kinds of number apart and labels each: counters since the function warmed up, and latencies over the log window. It refuses to report a 99th percentile from fewer than a hundred requests, rather than invent one. Over the API, GET /metrics gives counters in Prometheus format (chapter 17).

Idle functions

A warm function holds memory. So Zygo lets unused ones step down, in two stages. After idle_timeout (default ten minutes) with no request, the function is paused: frozen in place, still in memory, and thawed by the next request in a few milliseconds. After cold_after (default one hour) it is cold: the sandbox is dropped, only the spec is kept, and the next request pays the full warm-up again.

idle_timeout = "10m"
cold_after   = "1h"
  request ──▶ WARM ──(idle_timeout: 10m)──▶ PAUSED ──(cold_after: 1h)──▶ COLD
               ▲                              │                            │
               └────── next request: ~ms ─────┘                            │
               └────── next request: pays the whole warm-up again ─────────┘

zygo ps shows the state of each function. To bring one back before a real request arrives, call POST /fn/<name>/warm over the API, or client.warm(name) in the SDKs.

Capacity

Zygo’s capacity is a budget for one machine. Each function runs concurrency requests at once (default 4). Up to four times that many more wait in a queue, for at most five seconds. Past that, Zygo answers busy: HTTP 429 with the numbers, or exit code 75 from zygo exec. This is backpressure — a polite “not now” — and not a failure. The request never ran, so retrying it is safe and correct.

  concurrency = 4

  running  [■][■][■][■]                         4 at once
  queue    [·][·][·][·][·][·][·][·]…[·]         up to 16 more, 5 s at most
  more     ──▶ 429 busy (exit 75): never ran, retry later

Upgrading Zygo

A warm function cannot be carried across to a new Zygo binary. An upgrade is always: drain, exit, start the new version, warm up again. ADR 0004 is the study that explains why. To drain over the API:

curl -X POST -H "Authorization: Bearer $ZYGO_API_TOKEN" \
    "http://127.0.0.1:7700/drain?grace_ms=60000"

This stops taking new requests, finishes the running ones, and exits. The answer carries the number still running: in_flight: 0 is a clean drain, and anything else means the grace time ran out. The cost is a warm-up per function afterwards: about 150–185 ms for a Python handler, the supervisor’s own start included, measured on a Raspberry Pi 5 (chapter 25); heavy imports add to it. No request is dropped, as long as something else is ready to take them — which is what two replicas and min_warm are for.

  replica A: ███ serving ███ drain ▓▓ exit │ start new ░ warm ░ ███ serving ███
  replica B: ███████████████ serving ███████████████████████████ drain ▓▓ …
                            ▲ B takes all the traffic while A upgrades

Debugging a live function

zygo shell resize
zygo shell resize -- cat /proc/1/cgroup

zygo shell starts a new process inside the function’s namespaces. The warm zygote is not touched: it keeps its memory and keeps serving. The shell sees the sandbox’s files, processes, network and host name, and holds no capabilities. It is on purpose not under the seccomp filter, the Landlock rules or the tenant’s cgroup: a debug shell that the memory limit kills is no use to anyone.

Calling it from a program

zygo api                     # listens on 127.0.0.1:7700, bearer-token auth
import zygo_sdk as zygo
client = zygo.connect()
out = client.fn("resize")({"url": "..."}).result   # what the handler returned

The API starts call-only: a token can call the functions somebody declared in a spec file, and nothing else. --allow-deploy adds serving, stopping and one-shot runs, which together amount to a shell, not an API. ZYGO_API_TOKEN is the operator’s token. For a platform with customers, zygo token mint --tenant acme prints a token that registers scripts and calls functions for that customer only; the tenant comes from the token, never from anything the caller sends. Chapter 17 covers the API and SDKs in full.

Giving it to an agent

An AI agent can use Zygo through MCP, the Model Context Protocol, a standard way for agents to call tools. The whole installation is one entry in the agent’s configuration:

{ "mcpServers": { "zygo": { "command": "zygo", "args": ["mcp"] } } }

Chapter 17 describes the tools it offers.

Running Zygo inside a container

Zygo builds sandboxes, so a container it runs in must let it. It needs no privileges and no capabilities, and --privileged is not the answer. The container image asks for three things. A host with AppArmor needs a fourth, and sandboxes with a network need a fifth. zygo doctor names each one that is missing.

docker run --user 0:0 \
  --security-opt seccomp=unconfined \
  --security-opt systempaths=unconfined \
  --security-opt apparmor=unconfined \
  --cgroupns=host --cgroup-parent=/zygo \
  -v /sys/fs/cgroup/zygo:/sys/fs/cgroup/zygo:rw \
  --device /dev/net/tun \
  -v zygo-data:/var/lib/zygo \
  -p 7700:7700 -e ZYGO_API_TOKEN=... \
  ghcr.io/mhmtskrc2/zygo

--user 0:0 is there because the cgroup folder belongs to root. As the image’s own user, 65532, Zygo could not write it. No sandbox runs as that root: each one is in a user namespace of its own.

With Docker’s systemd cgroup driver — the default on Ubuntu and Debian — the parent must be a slice: --cgroup-parent=zygo.slice and /sys/fs/cgroup/zygo.slice.

The three things, and two more

  ┌─ the container Zygo runs in ──────────────────────────────────────────┐
  │                                                                       │
  │  1. seccomp=unconfined      ─▶ may call unshare(CLONE_NEWUSER)        │
  │  2. systempaths=unconfined  ─▶ /proc not masked, so a fresh /proc     │
  │                                can be mounted in a user namespace     │
  │  3. its own cgroup subtree  ─▶ somewhere to put each sandbox          │
  │  4. apparmor=unconfined     ─▶ (AppArmor hosts) mounts not refused    │
  │  5. /dev/net/tun            ─▶ (egress / full) pasta's network card   │
  │                                                                       │
  │   ┌──────────┐ ┌──────────┐ ┌──────────┐                              │
  │   │ sandbox  │ │ sandbox  │ │ sandbox  │  ◀── the real boundary       │
  │   └──────────┘ └──────────┘ └──────────┘                              │
  └───────────────────────────────────────────────────────────────────────┘
NeedWhyIn Kubernetes
A seccomp profile that allows unshare(CLONE_NEWUSER)Docker’s default profile denies it, and it is the first thing a sandbox doessecurityContext.seccompProfile: {type: Unconfined}
An unmasked /procruntimes cover parts of /proc (kcore, acpi …); the kernel then refuses a new proc mount inside a user namespace, because the old one is not fully visible. Sandboxes die on “mounting /proc failed: Operation not permitted”privileged: true gives one. securityContext.procMount: Unmasked is accepted only with hostUsers: false
A writable cgroup v2 subtree of its ownevery sandbox goes in a cgroup, and the container’s /sys/fs/cgroup is read-onlyno field exists; see below
No AppArmor profile, on AppArmor hostsDocker’s docker-default profile denies mount; zygo doctor reports “the mount tree could not be made private”securityContext.appArmorProfile: {type: Unconfined}
/dev/net/tun, for egress and fullpasta gives a sandbox its network card through it, and runtimes leave the device node outa hostPath of type CharDevice

Give it its own cgroup, not the host’s

The usual advice is to mount all of /sys/fs/cgroup read-write. That works, but it hands the container the host’s whole cgroup tree: it could then change limits on any cgroup on the machine, including other containers’. That is worse than the privilege you were trying to avoid. Give Zygo a subtree of its own instead, as the command above does with /zygo. Sealed sandboxes — network = "none", the default — do not need /dev/net/tun.

The host’s AppArmor still applies

A host’s own AppArmor rules still reach into a container. Ubuntu’s passt profile attaches to the path /usr/bin/pasta and refuses the pid file Zygo asks for. An image that installs pasta somewhere else on PATH is not affected, and the Zygo image puts nothing at /usr/bin/pasta for exactly this reason (chapter 22 has the error and the fix).

Why this is safe enough

A container set up this way is no easier to escape than the host it runs on. That is the point: the wall Zygo enforces is the one it builds inside — the sandboxes — not the container around it. The test scripts tests/linux/verify_supervisor.sh and tests/linux/verify_api.sh run in exactly this shape in CI, unprivileged, on every change. make verify-oci builds the image and runs a sandbox inside it the same way.

The container image

make oci-image      # build it from the binary make already checked
make verify-oci     # build it and run a sandbox inside it, unprivileged

The published image is ghcr.io/mhmtskrc2/zygo:<version>, for linux/amd64 and linux/arm64. It is signed with cosign and listed in the release’s SHA256SUMS. It is Alpine plus a few programs:

In the imageWhy
zygothe static musl binary, the one tests/linux/check_dist.sh checked for size
pasta, nft, tcwhat network = "egress" needs; without them egress is refused with a reason, never quietly opened
newuidmap, newgidmaphow a non-root user maps a range of user ids; without them every tenant maps to one host uid and that separation is lost
user 65532nonroot, the number distroless images use, with a 65536-wide range of sub-ids
no Python, no Nodeeach sandbox runs in an image of its own; what a handler can import comes from the image it names

The data folder is /var/lib/zygo (ZYGO_DATA_HOME), a volume. The image has a health check on /healthz.

No Python in the Zygo image

That last row surprises people. The Zygo image does not need Python, because your function never runs in it: it runs in python:3.12-slim or whatever image the spec names, pulled into Zygo’s store. If you want those images already on the node, so a cold start is not a trip to the registry, build a worker image: Dockerfile.worker runs zygo pull at build time and is a few lines long. It trades a bigger image for no waiting; pull only the images you really serve.

The image is call-only by default

The image’s default command is api --listen 0.0.0.0:7700, with no --allow-deploy. A token can call the functions somebody declared and nothing else. Deploy rights over HTTP are a shell, and a default that hands them out is one nobody reads the flag for. Pass --allow-deploy when the thing in front of the API is your own control plane. The worker image does.

Verifying a published image

cosign verify ghcr.io/mhmtskrc2/zygo:0.1.5 \
  --certificate-identity-regexp '^https://github\.com/.*/\.github/workflows/release\.yml@refs/tags/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

This is keyless signing: the signature is tied to the GitHub workflow that built the image and the tag it was built from. There is no private key to keep safe, and none to leak.

Zygo on Kubernetes

examples/kubernetes/ has a working manifest. A CI job named kubernetes applies exactly this file to a fresh kind cluster on every change and runs a sandbox in it, so the file is tested, not just an illustration.

kubectl apply -f examples/kubernetes/deployment.yaml
kubectl -n zygo port-forward svc/zygo 7700:7700
  namespace zygo
  ┌─────────────────────────────────────────────────────────────────────┐
  │ Secret zygo: api-token, secrets-key                                 │
  │ Service zygo :7700 ─────────┬──────────────────────┐                │
  │                             ▼                      ▼                │
  │  ┌─ pod 1 ─────────────────────────┐  ┌─ pod 2 ─────────────────┐   │
  │  │ zygo api --allow-deploy         │  │ (same)                  │   │
  │  │ securityContext: see below      │  │                         │   │
  │  │ /var/lib/zygo  emptyDir 20Gi    │  │                         │   │
  │  │ /run/zygo      emptyDir, memory │  │                         │   │
  │  │ readiness: /healthz             │  │                         │   │
  │  │ liveness:  zygo doctor --json   │  │                         │   │
  │  │ preStop:   POST /drain          │  │                         │   │
  │  └─────────────────────────────────┘  └─────────────────────────┘   │
  └─────────────────────────────────────────────────────────────────────┘
     replicas: 2 · maxUnavailable: 0 · maxSurge: 1 · no hostPath anywhere

The Secret holds two values: the operator’s API token, from which every customer token is minted, and the key that seals per-tenant secrets (chapter 14). The pod asks for 1 CPU and 2 GiB, and sets a memory limit of 8 GiB but no CPU limit: each tenant’s CPU is already capped by a cgroup Zygo sets per request, and a pod-wide CPU ceiling would also slow the supervisor itself.

What securityContext is for, and privileged: true

A pod needs the same things as the container above. One has a field of its own, two come with privileged: true, and one is a node setting:

seccompProfile: Unconfinedthe default profile denies unshare(CLONE_NEWUSER)
an unmasked /proca masked /proc stops a fresh proc mount in a user namespace; privileged: true leaves it unmasked
a writable cgroup v2 subtreeno field says this; privileged: true with runAsUser: 0 is the way to get one
unprivileged user namespacesa setting on the node (kernel.unprivileged_userns_clone, AppArmor on Ubuntu), not the pod

privileged: true is there for the third row, together with runAsUser: 0: the pod’s cgroup belongs to root, and privileged gives capabilities to root only. It also brings an unmasked /proc, /dev/net/tun and no AppArmor profile, which a pod without it would have to ask for as the table above describes. The field for the first, procMount: Unmasked, is refused by Kubernetes unless the pod also sets hostUsers: false, so the example does not use it. The cost is smaller than it looks, because the wall Zygo enforces is the sandbox it builds inside the pod — namespaces, seccomp, Landlock, a cgroup per request — not the pod itself. Still, run it on nodes of its own, and read chapter 23. The day Kubernetes can say “give this pod its own cgroup subtree”, that line goes, and nothing else changes.

Rolling out without dropping a request

Three settings together: maxUnavailable: 0, so no pod stops before its replacement is ready; a preStop hook that calls POST /drain; and a terminationGracePeriodSeconds (120 in the example) longer than your longest request. Kubernetes does these in the right order: it takes the pod out of the Service before preStop runs, so the drain only finishes work already accepted. /healthz answers 503 from the moment a drain starts, which also takes the pod out of any load balancer that watches readiness.

  kubectl rollout
     │
     ├─ start new pod ─▶ startupProbe /healthz passes (warm) ─▶ ready
     ├─ old pod removed from the Service's endpoints
     ├─ preStop: POST /drain?grace_ms=60000
     │      stop admitting · finish in-flight · reply {in_flight: 0} · exit
     └─ old pod gone          (terminationGracePeriodSeconds: 120)

Log the drain’s answer: in_flight: 0 is clean, and anything else means the grace time ran out.

Three probes, three questions

ProbeChecksWhy
startupProbeGET /healthz, every 2 s, up to 30 timesa warm pool takes as long as its interpreter; until this passes, the others cannot fail the pod
readinessProbeGET /healthz“should this pod get work?” It says no while draining, without killing the pod
livenessProbezygo doctor --json“is this pod broken?” A liveness check on /healthz would restart a pod that is draining on purpose; doctor asks whether the host can still build sandboxes

The image store on Kubernetes

The store is an emptyDir, because it is a cache: a pod that moves to another node pulls again what it needs. The things that are not a cache — tenants, tokens, sealed secrets — belong in your control plane’s database, with the API as the way in. Two alternatives are both fine: a PVC (a persistent volume), if re-pulling costs you more than a volume to manage, or a worker image with the images baked in, which costs nothing at run time. No hostPath appears anywhere, so pods can be scheduled freely.

Trying it on kind

kind.yaml creates a one-node kind cluster (Kubernetes in Docker) that can run the manifest. It needs cgroup v2 on the host. A real cluster does not need this file, but it needs cgroup v2 too.

What the Kubernetes example leaves out

  • An Ingress. The API belongs to your control plane, not the internet. Put a service of your own in front of it.
  • Autoscaling. A warm pool costs memory, not CPU. The signal that matters is how many requests are waiting, and GET /metrics does not publish that yet: it has requests, failures, memory and state per function (chapter 17). Until it does, scale on memory (zygo_function_rss_bytes) and on the 429 answers your callers see.
  • A PodSecurityPolicy or Gatekeeper rule. A cluster that enforces one will need an exception for this namespace, and that exception is yours to write.

Choosing an isolation backend

isolation = "ns"     # ns | gvisor | vm

A backend is the kind of wall between the sandbox and the host. You change it with one field; nothing else in the spec changes.

  ns       your code ─▶ seccomp, Landlock, namespaces ─▶ host kernel
  gvisor   your code ─▶ gVisor (a kernel in user space) ─▶ host kernel
  vm       your code ─▶ guest kernel ─▶ KVM (hardware) ─▶ host kernel
           ─────────────────────────────────────────────────────────▶
           cheaper, and warm functions               stronger wall
nsgvisorvm
Wallnamespaces, cgroups, seccomp, Landlock; the host’s kernela user-space kernel between you and the host’sa guest kernel under KVM
One-shot runsyesyesyes
Warm functionsyes, the only onerefusedrefused
Networkingyesrefusedrefused
Setupnonezygo backend install gvisora --features vm build and a guest kernel

The three backends in detail

ns uses one kernel, shared with the host, with every lock the kernel offers turned on. Every number in this book is measured on it. gvisor puts a kernel written in user space between the sandbox and yours: a smaller attack surface, at a cost on every syscall. Warm functions and networked sandboxes on it are refused with a reason, never weakened. vm is a hardware wall: libkrun and KVM, with a guest kernel of its own. On the Raspberry Pi 5 a one-shot run took about 420 ms, against about 73 ms for ns on the same machine. The guest writes to a private layer, bounded by scratch, and nothing it writes reaches the shared image or the next sandbox. It has no networking and no warm functions yet.

Installing a backend

zygo backend list        # what this host can actually use
zygo backend install gvisor
zygo run --isolation gvisor python:3.12-slim python3 -c 'import platform; print(platform.release())'

zygo backend install gvisor downloads gVisor’s runsc from its official release bucket on storage.googleapis.com and checks its sha512 before unpacking it. The vm backend needs a Zygo binary built with --features vm (or make vm-build) and a guest kernel file at <data>/backends/krun/Image. There is no published kernel to download yet: zygo backend install vm tells you to build one with make vm-kernel and where to copy it. zygo doctor reports it once it is in place.

Which backend to pick

Use ns for code you chose, or code you half trust. For code you did not choose, use the strongest wall you can get. Read chapter 23 before you trust any of them with something that matters — in particular the part about where the wall is weaker than it looks. ADR 0002 explains why warm functions stay on ns.

What Zygo will not do

This list is plain on purpose: a tool that is vague about its limits is worse than one that lacks a feature.

  • Run on macOS or Windows natively. Sandboxes are a Linux feature. On a Mac, Zygo manages a Linux VM for you.
  • Accept connections. No mode of Zygo serves your traffic. A function is called through the CLI, the SDKs or Zygo’s own HTTP API; you put your own ingress in front of that.
  • Scale past one machine. Capacity is a budget per host, and a 429 past it.
  • Replace Docker. Zygo uses OCI images and none of Docker’s runtime. If you need docker compose, long-running services or published ports, you need Docker.
  • Analyse the code it runs. Zygo confines hostile code. It does not tell you the code was hostile: there is no audit mode, no network log and no verdict.
  • Hide the kernel. The ns backend shares one kernel with the host, and this book says so everywhere.

17. The HTTP API, the SDKs and MCP

The CLI is one way in. Programs use the HTTP API — directly, or through the Python, Node and Elixir clients — and agent hosts use the MCP server. All of them end at the same supervisor, the long-lived Zygo process that owns the warm sandboxes.

  your program ──▶ Python/Node/Elixir ─┐
  curl ────────────────────────────────┤  HTTP  ┌──────────┐ unix socket ┌────────────┐
                                       └───────▶│ zygo api │────────────▶│            │
                                                └──────────┘             │            │
  CLI (zygo exec, zygo up, …) ──────────────────────────────────────────▶│ supervisor │
                                                                         │            │
  agent host ──▶ zygo mcp (stdin/stdout) ───────────────────────────────▶│            │
                                                                         └────────────┘

This chapter has three parts. First the HTTP API itself: how to start it, who may call what, the routes and the answers. Then the three SDKs, and the features they reach: tenants, secrets, files, streaming, cancelling, runtime pools and dependency sets. Last, the MCP server for agent hosts.

Part one: the HTTP API

zygo api is a small web server. It turns HTTP requests into messages for the supervisor, and turns the supervisor’s answers back into HTTP. It does not run sandboxes itself. Every boundary a sandbox has is built by the zygo binary and enforced by the kernel, so nothing a caller sends over HTTP can do more than the rules below allow.

Starting the API

zygo api runs the API in the foreground; run it under systemd or in a container for production. It listens on 127.0.0.1:7700, or where [api] listen or --listen says — an IP:PORT or unix:///path (created with mode 0600, so only your user can open it). By default every caller needs a bearer token — a secret string sent in the Authorization header — on every kind of listener, unix sockets included. --no-auth (or [api] auth = "none") turns that off, and is refused anywhere but a unix socket or a loopback address. So a unix socket or loopback only permits turning auth off; it never skips it by itself. zygo api --openapi prints the OpenAPI 3.1 document for this build; the same file is committed as spec/openapi.json.

Who is calling: tokens and tenants

  ZYGO_API_TOKEN (bootstrap) ───────┐
                                    ├──▶ OPERATOR: the host's owner. May act for
  operator token (zygo token mint) ─┘    any tenant with the X-Zygo-Tenant header.

  tenant token (zygo token mint --tenant acme) ──▶ TENANT acme: sees and calls
                                                   only its own things.

A tenant is one customer of whoever embeds Zygo. A token proves who is calling, and the caller cannot choose it. The bootstrap token comes from ZYGO_API_TOKEN; more are minted with zygo token mint (secrets look like zygo_ plus 64 hex characters). Only a hash is stored, so a lost token cannot be shown again — revoke it and mint another. A revoke works from the next request. An operator acting for a tenant sends X-Zygo-Tenant: acme; a tenant token that sends a different tenant gets 403. The sections Tenants and Tokens below show both from the SDKs.

The deploy gate

Calling a function that exists is one thing; creating sandboxes is another, because whoever can create one can run any image with any mount as your user. That is a shell, not an API. So zygo api starts call-only: a token reaches the functions somebody declared in a spec file that was reviewed, and nothing else. The routes that create, change or destroy things need deploy rights. An operator token minted with zygo token mint always has them, because minting it already needed them. The bootstrap token has them only when zygo api was started with --allow-deploy. A tenant token never does, whatever the flag says: one customer does not get to name an image because another is trusted.

  request with a valid token
        │
        ▼
  route needs deploy rights? ──no──▶ allowed (a tenant sees only its own things)
        │ yes
        ▼
  tenant token? ──yes──▶ 403
        │ no
        ▼
  operator token minted with `zygo token mint` ──▶ allowed
  bootstrap ZYGO_API_TOKEN ──▶ allowed only if `zygo api --allow-deploy`, else 403

Which routes are gated

These need deploy rights: PUT and DELETE /fn/{name}, POST /run, POST and DELETE /runtimes, PATCH /tenants/{id}/limits, PUT and DELETE /tenants/{id}/secrets/{name}, DELETE /tenants/{id}, DELETE /scripts/{digest}, DELETE /blobs/{digest}, DELETE /deps/{id}, POST /drain, and every token route. POST /tenants and GET /tenants are for the operator only, but do not need deploy rights.

These are not gated, and any valid token may use them: PUT /scripts, GET /scripts/{digest}, PUT /blobs, POST /deps, GET /runtimes, POST /runtimes/{name}/call and DELETE /requests/{id}. Registering code that still needs a pool to run, and calling a pool somebody else declared, is exactly what a call-only token is for.

Why each one is gated

Serving a function, a one-shot run and creating a pool all name an image and mounts, which is running code as the user Zygo runs as. Deleting a script, a blob or a dependency set is gated because each store is shared by digest or id: forgetting one forgets it for every tenant that sent the same bytes. Limits, secrets and tokens are the operator’s relationship with a customer, and draining stops the host serving anybody. Without the rights, the SDK raises AuthError; the message names the flag for an operator, and says what a tenant token is for instead.

What stays off for everybody

A request body can never remove a guarantee. Host networking and unlimited limits are refused over HTTP whatever the caller and whatever the body says. Set those where the sandbox is declared, in sandbox.toml (see chapter 20).

Private-range egress has one more place: the command that starts the API. zygo api --allow-deploy --allow-private-net lets what deploy callers serve — PUT /fn/{name}, POST /runtimes, POST /run — name private and link-local addresses in allow. It opens nothing by itself: a rule still names the address and the port, and everything else in those ranges stays shut. It is for an embedder whose scripts must call back to a service of its own, which chapter 14 says to put on an address the host really has. Without --allow-deploy it does nothing, because only a deploy call declares a sandbox, and GET /version reports it as private_net for callers who can use it.

  zygo api --allow-deploy                       zygo api --allow-deploy --allow-private-net
  POST /runtimes  allow=["10.0.0.5:4010"]       POST /runtimes  allow=["10.0.0.5:4010"]
    → 400 bad_spec: targets a private range       → served; 10.0.0.5:4010 and nothing else

Request and response headers

HeaderDirectionMeaning
Authorization: Bearer …inThe token.
X-Zygo-TenantinWhich tenant an operator is acting for.
X-Zygo-Timeout-MsinHow long the caller will wait; up to 24 hours. The default wait is 60 s.
X-Zygo-Request-KeyinThe caller’s own name for a request (1–128 printable characters), to cancel it by.
X-Zygo-Request-IdoutZygo’s id for the request.
Retry-AfteroutOn 429 (1 s), and on 503 while dependencies build (5 s).

The routes

“any” means any valid token; “op” means operator; “deploy” means deploy rights. Bodies are JSON unless marked raw; the limit is 16 MiB.

RouteWhoWhat it does
GET /healthznobody needs a tokenok, degraded (a pool below min_warm) or stopping (503).
GET /versionanyZygo version, API version, and whether you have deploy rights.
GET /metricsanyPrometheus text (below); scoped to the token.
POST /drain?grace_ms=deployStop taking requests, finish the running ones, exit.
GET /fnanyFunctions you can see: state, image, memory, request counts.
PUT /fn/{name}deployServe or replace a function. Body: {layer, base_dir, secrets?, if_changed?}.
DELETE /fn/{name}deployStop it.
POST /fn/{name}anyCall it. Body: the event. ?stream=1 for live output; ?out=1 to get /work back as a tar; ?workspace=sha256:… to send one in.
POST /fn/{name}/batchanyCall it with an array of events (up to 1024); an array of answers comes back.
GET /fn/{name}/logsanyRecent log entries: ?after=, ?limit=, ?failed=.
GET /fn/{name}/statsanyOne function’s status.
POST /fn/{name}/warmanyWake a paused or cold function now.
GET /runtimesanyPools and their load.
POST /runtimesdeployStart a pool: {name, layer, base_dir?, deps?}. layer.secrets names the secrets a call may receive; values are the calling tenant’s.
DELETE /runtimes/{name}deployStop a pool.
POST /runtimes/{name}/callanyRun a script in a pool: {script, event?, entry_point?, workspace?}.
PUT /scriptsanyStore a script (raw body); returns its sha256.
GET / DELETE /scripts/{digest}any / deployCheck or remove one.
PUT /blobsanyStore a tar (raw body), for workspaces.
GET / DELETE /blobs/{digest}any / deployCheck or remove one.
POST /depsanyBuild a dependency set from lock files: {image, files}. 202 while building.
GET /deps, GET /deps/{id}anyTheir state, and the build log.
DELETE /deps/{id}deployRemove one no pool uses.
POST /tenants · GET /tenantsopCreate or list tenants. 400 on a host whose user has no subordinate uid range, unless the supervisor runs with ZYGO_ALLOW_SHARED_UID=1 (chapter 23).
GET /tenants/{id}that tenant, or opOne tenant: scripts, limits.
DELETE /tenants/{id}deployRemove it, its scripts and its functions.
PATCH /tenants/{id}/limitsdeployNarrow its limits: mem, cpu, pids, timeout, scratch, network, allow.
GET /tenants/{id}/secretsthat tenant, or opSecret names — never values.
PUT / DELETE /tenants/{id}/secrets/{name}deploySet (raw body) or remove one.
POST /tokens · POST /tenants/{id}/tokensdeployMint an operator or tenant token.
GET /tokens · DELETE /tokens/{id}deployList or revoke.
DELETE /requests/{id}anyCancel your own request, by id or by key.
POST /rundeployA one-shot sandbox: {layer, stdin?}; returns exit code, output, and why it ended.

A layer in a body is one function’s table from sandbox.toml, sent as JSON: the same fields [fn.NAME] takes — image, entry or cmd, mem, network, allow, secrets, and so on — with every field optional, exactly as chapter 20 lists them. It is called a layer because it is merged over [defaults] the way a [fn.NAME] table is. base_dir is the folder on the server that its relative paths (entry, mounts, requirements) are relative to.

A batch runs at most 16 of its events at the same time. ?workspace= on a function call takes only a blob digest, never an inline tar (see Files in and out).

Status codes

CodeMeaning
200 / 201 / 202Done / created / still building.
400A bad spec, header or body.
401No token, or a wrong or revoked one.
403Your token may not do this: not the operator, no deploy rights, or the wrong tenant.
404 · 405No such thing · wrong method.
408The function’s own timeout killed the request. On POST /run, only when the API’s own outer deadline fired (see below).
413Body over 16 MiB, or a batch over 1024.
422A tenant limit above what could ever apply.
429Busy: every slot and the queue are full. Retry after the header.
499The request was cancelled.
500The handler raised: the body has error, stdout, stderr, exit_code.
503Warming failed, dependencies still building, or the API is stopping.
504The request stopped answering heartbeats: stuck.

Errors always have the shape {"error": "…", "code": "…"}.

A successful call

{
  "result":     {"count": 3},
  "request_id": "req-01f3…",
  "stdout":     "",
  "stderr":     "",
  "metrics":    {"wall_ms": 4.1, "cpu_ms": 2.3, "peak_rss_kb": 18420}
}

A streamed call (?stream=1) answers application/x-ndjson: one line per event, {"stream": "stdout" | "stderr" | "progress", "data": …}, then a last line with the body above and a status. Watching a request explains it.

The answer of a one-shot run

POST /run answers with more than an exit code, because the exit code alone cannot carry what a caller needs:

{"exit_code": 137, "timed_out": false, "oom_killed": true,
 "peak_rss_kb": 65536, "wall_ms": 412.7, "stdout": "", "stderr": "",
 "started": true, "phase": "run"}

A deadline kill and an out-of-memory kill are both SIGKILL, so both show exit code 137. timed_out comes from the launcher, which enforced the deadline; oom_killed comes from the kernel’s own counter in the sandbox’s cgroup (the kernel group that holds its limits). Neither is a guess. A caller deciding between “too slow” and “too much memory” — an online judge, a CI step — has nothing else to go on.

started, and when POST /run says 408

started is whether the program ran at all. false, with phase naming what failed (plan or start), is Zygo failing to build the sandbox. A caller reports that as unavailable, not as the code’s failure, and ok is false for it. An API one release behind does not send the field, and only answered once the program had run, so the SDKs default it to true.

The answer is a 200 whenever the sandbox ran, whatever ended it: a non-zero exit, its own timeout, an out-of-memory kill. The body says which. It is a 408 only when the API’s own outer deadline fired, the child was killed, and what it would have said is unknown — Zygo failing to finish, not a sandbox doing its job.

Metrics, OTLP and usage events

/metrics gives Prometheus series: zygo_api_requests_total, zygo_api_errors_total, and per function zygo_function_requests_total, zygo_function_failures_total, zygo_function_rss_bytes and zygo_function_state. --otlp-endpoint URL pushes the same numbers, plus per-tenant requests, outcomes, CPU and wall time, to an OpenTelemetry collector. --usage-webhook URL POSTs batches of usage events for billing — {tenant, function, script, request_id, wall_ms, cpu_ms, peak_rss_kb, outcome, finished_ms} — at least once, so key on request_id. Both count only requests that went through this API process.

Who sees what on /metrics. It needs a token, and the answer depends on the token, the same way GET /fn does. A tenant token gets zygo_api_requests_total and zygo_api_errors_total, which carry no names, and the per-function series for its own functions only. An operator token gets every function on the host. There are still no per-tenant series; the OTLP push and the usage webhook are where per-tenant numbers live.

Usage, for billing has the detail on the usage events.

Part two: the SDKs

There are three clients over the HTTP API: Python, Node and Elixir. Python and Node have no dependencies: the standard library has an HTTP client in each language, and a unix socket is a few lines on top of it. Erlang’s HTTP client keeps its connections to itself, so the Elixir client has two small dependencies instead (see The Elixir client). An SDK for a runtime whose point is a small, auditable boundary should not arrive with a dependency tree of its own. None is a second implementation of Zygo, and nothing in any client can widen a sandbox.

  your process ──HTTP──▶ zygo api ──control socket──▶ supervisor ──▶ warm sandboxes

On one machine the HTTP hop can be a unix socket at mode 0600, so there is no port. Across machines it is TCP. Either way a bearer token is needed, unless the API was started with auth turned off on a unix socket or loopback address.

The Python client

pip install zygo-sdk        # or: pip install -e sdk/python; then `import zygo_sdk`

The module is zygo_sdk, not zygo: a different, older project owns the name zygo on PyPI, and two packages that install the same module name overwrite each other. import zygo_sdk as zygo keeps the short name in your code.

No dependencies. zygo.connect() finds the API from its url argument, then ZYGO_API_URL, then http://127.0.0.1:7700, and its token from token= or ZYGO_API_TOKEN.

import zygo_sdk as zygo
client = zygo.connect()
res = client.fn("resize")({"url": "…"})               # a call; ~1.4 ms of overhead
print(res.result)                                      # what the handler returned
res = client.call("resize", {"url": "…"}, timeout=5)   # the same call, with a timeout
print(res.result, res.stdout, res.metrics.wall_ms)     # a Result: value, output, timings
for ev in client.stream("resize", {"url": "…"}):       # live output
    print(ev.kind, ev.data)

The client covers every route but GET /metrics, which is for a scraper: call, batch, stream, cancel, logs, stats, warm, serve, stop, run, functions; pools with serve_runtime, run_script, stream_script, put_script; put_deps, put_blob; tenants, secrets, limits and tokens; for_tenant(id); health, version, drain. What the clients can do lists every method.

The async Python client

zygo.aio is an asynchronous client, which is what an agent framework needs:

import asyncio
import zygo_sdk as zygo

async def main(events):
    async with zygo.aio.connect() as client:
        return await asyncio.gather(*(client.call("resize", e) for e in events))

asyncio.run(main(events))

It is the whole API, not a subset: every method of the plain client, with the same name and arguments, as a coroutine — tenants, tokens, secrets, limits, blobs, drain, for_tenant, and workspace/out on call and run_script included. A test holds the two surfaces equal. zygo.aio is reachable after import zygo_sdk as zygo; import zygo_sdk.aio works too. Cancelling the task cancels the request on the server.

The Node client

npm install zygo-sdk

No dependencies, Node 18 or newer, ES modules. The same methods in camelCase — runScript, serveRuntime, putSecret — with timeouts in seconds and an AbortSignal that cancels the request for you.

import { connect } from 'zygo-sdk';
const client = connect();                         // ZYGO_API_URL, ZYGO_API_TOKEN
const resize = client.fn('resize');
const out = await resize({ url: 'https://example.com/a.png' });
console.log(out.result, out.metrics.wallMs);

The TypeScript types

TypeScript types ship beside the JavaScript in index.d.ts. They are written by hand rather than compiled, so the package has no build step and what you read in the repository is what runs. A test (sdk/node/test/types.test.js) reads the declaration file against the module as it runs — every export, every Client method, every option a call reads — so a method added without a declaration fails the suite rather than shipping untyped.

The Elixir client

# mix.exs
{:zygo_sdk, "~> 0.1"}

Elixir 1.18 or newer, for the JSON module in the standard library. The same methods as Python, with the same snake_case names, as functions in the Zygo module that take the client first. Every call to the API returns {:ok, value} or {:error, %Zygo.Error{}}, and has a ! twin that returns the value or raises. The two streams yield an error as an element instead, and their ! twins raise it. Durations — timeout:, backoff:, retry_after — are milliseconds, as is usual in Elixir.

client = Zygo.connect()                                  # ZYGO_API_URL, ZYGO_API_TOKEN
{:ok, out} = Zygo.call(client, "resize", %{"url" => "…"}, timeout: 5_000)
IO.inspect({out.result, out.stdout, out.metrics.wall_ms})

There is no fn(name) handle: fn is a keyword in Elixir, and &Zygo.call(client, "resize", &1) is the same thing in one line.

Processes, and the pool

The client is a plain struct, so any process may use it. Its connections live in a pool process, which Zygo.connect/2 starts linked to the caller. A client that should live as long as your application goes under your own supervisor instead, and is fetched by name:

children = [{Zygo, name: MyApp.Zygo, url: "unix:///run/zygo/api.sock", retries: 3}]
Zygo.functions!(Zygo.client(MyApp.Zygo))
  caller process ──checkout──▶ ┌── Zygo pool ─────────────┐
    (sends, reads the answer)  │ idle connection  ·  12 s │
  ◀──────────────connection─── │ idle connection  ·   3 s │
  caller process ──checkin───▶ │ (up to pool_size: 32)    │
                               └──────────────────────────┘

A caller borrows one connection for one request and gives it back. The socket moves with it, so a caller that crashes mid-request takes its socket down rather than leaving a half-read answer in the pool. More callers than pool_size wait for a connection to come free. The pool is NimblePool, and the connections are Mint, which is HTTP as a data structure rather than a process and opens a unix socket. Those two are the package’s only dependencies.

Errors, and streams

The other clients raise one class per kind of failure. The Elixir client has one exception, Zygo.Error, and its kind field says which failure it was: :busy, :handler, :timeout and so on (see Errors in the clients). A case on the kind is how a caller branches:

case Zygo.call(client, "resize", event) do
  {:ok, out} -> out.result
  {:error, %Zygo.Error{kind: :busy, retry_after: ms}} -> {:later, ms}
  {:error, %Zygo.Error{kind: :handler, stderr: stderr}} -> {:bug, stderr}
end

Zygo.stream/4 returns a lazy Stream, and nothing is sent until it is read. Its items are {:stdout, text}, {:stderr, text} and {:progress, text}, then one last item: {:result, %Zygo.Result{}}, or {:error, %Zygo.Error{}}. A refusal before anything ran is that error alone. Zygo.stream!/4 raises the error instead, after the output before it.

What the clients can do

Who is the token the call is made with: a tenant token is one customer’s, an operator token is the host’s. Deploy marks the calls that need deploy rights (see The deploy gate).

PythonNodeElixirWhoDeploy
Call a warm functionclient.call(name, event)client.call(name, event)Zygo.call(client, name, event)eitherno
A callable for one functionclient.fn(name)client.fn(name)&Zygo.call(client, name, &1)eitherno
Several events at onceclient.batch(name, events)client.batch(name, events)Zygo.batch(client, name, events)eitherno
List functionsclient.functions()client.functions()Zygo.functions(client)eitherno
Countersclient.stats(name)client.stats(name)Zygo.stats(client, name)eitherno
Warm one nowclient.warm(name)client.warm(name)Zygo.warm(client, name)eitherno
Recent logclient.logs(name)client.logs(name)Zygo.logs(client, name)eitherno
Versionclient.version()client.version()Zygo.version(client)eitherno
Healthclient.health()client.health()Zygo.health(client)anyoneno
Drain the hostclient.drain(grace)client.drain(grace)Zygo.drain(client, grace: ms)operatoryes
Register a scriptclient.put_script(source)client.putScript(source)Zygo.put_script(client, source)eitherno
Build a dependency setclient.put_deps(image, files)client.putDeps(image, files)Zygo.put_deps(client, image, files)eitherno
How a build wentclient.deps(id)client.deps(id)Zygo.deps(client, id)own, or operatorno
Stop a running requestclient.cancel(id)client.cancel(id)Zygo.cancel(client, id)own, or operatorno
Limit a tenantclient.set_limits(id, **keys)client.setLimits(id, keys)Zygo.set_limits(client, id, keys)operatoryes
A tenant’s secret namesclient.secrets(id)client.secrets(id)Zygo.secrets(client, id)own, or operatorno
Set oneclient.put_secret(id, name, v)client.putSecret(id, name, v)Zygo.put_secret(client, id, name, v)operatoryes
Forget oneclient.delete_secret(id, name)client.deleteSecret(id, name)Zygo.delete_secret(client, id, name)operatoryes
Store a blobclient.put_blob(tar)client.putBlob(tar)Zygo.put_blob(client, tar)eitherno
Look one upclient.blob(digest)client.blob(digest)Zygo.blob(client, digest)eitherno
Forget oneclient.delete_blob(digest)client.deleteBlob(digest)Zygo.delete_blob(client, digest)operatoryes
Watch a call’s outputclient.stream(name, event)client.stream(name, event)Zygo.stream(client, name, event)eitherno
The same, for a poolclient.stream_script(rt, script)client.streamScript(rt, script)Zygo.stream_script(client, rt, script)eitherno
Look a script upclient.script(digest)client.script(digest)Zygo.script(client, digest)eitherno
Run a script in a poolclient.run_script(runtime, script)client.runScript(runtime, script)Zygo.run_script(client, runtime, script)eitherno
List runtime poolsclient.runtimes()client.runtimes()Zygo.runtimes(client)eitherno
Read a tenantclient.tenant(id)client.tenant(id)Zygo.tenant(client, id)own, or operatorno
Act for a tenantclient.for_tenant(id)client.forTenant(id)Zygo.for_tenant(client, id)operatorno
List tenantsclient.tenants()client.tenants()Zygo.tenants(client)operatorno
Create a tenantclient.create_tenant(id)client.createTenant(id)Zygo.create_tenant(client, id)operatorno
Serve a functionclient.serve(name, layer)client.serve(name, layer)Zygo.serve(client, name, layer)operatoryes
Stop oneclient.stop(name)client.stop(name)Zygo.stop(client, name)operatoryes
One-shot sandboxclient.run(image, cmd)client.run(image, cmd)Zygo.run(client, image, cmd)operatoryes
Forget a scriptclient.delete_script(digest)client.deleteScript(digest)Zygo.delete_script(client, digest)operatoryes
Forget a dependency setclient.delete_deps(id)client.deleteDeps(id)Zygo.delete_deps(client, id)operatoryes
Delete a tenantclient.delete_tenant(id)client.deleteTenant(id)Zygo.delete_tenant(client, id)operatoryes
Serve a runtime poolclient.serve_runtime(name, layer, secrets=[…])client.serveRuntime(name, layer, { secrets })Zygo.serve_runtime(client, name, layer, secrets: […])operatoryes
Stop oneclient.stop_runtime(name)client.stopRuntime(name)Zygo.stop_runtime(client, name)operatoryes
Mint a tokenclient.mint_token(tenant)client.mintToken(tenant)Zygo.mint_token(client, tenant)operatoryes
List tokensclient.tokens()client.tokens()Zygo.tokens(client)operatoryes
Revoke oneclient.revoke_token(id)client.revokeToken(id)Zygo.revoke_token(client, id)operatoryes

A listing is scoped to the caller: functions() and runtimes() through a tenant token show that tenant’s names, and nobody else’s. (/metrics is scoped the same way; see Metrics, OTLP and usage events.)

Connecting

All three clients find the address in the same order: the argument, then ZYGO_API_URL, then http://127.0.0.1:7700. These forms are accepted:

unix:///run/user/1000/zygo/api.sock     a local API over a unix socket
http://127.0.0.1:7700                   the default
https://zygo.internal:8443              across a network
box:9000                                bare host and port

The token comes from ZYGO_API_TOKEN unless one is passed. That is the same variable the server reads, so a shell that can start the API can talk to it. A unix socket still needs the token, unless the API was started with --no-auth. Every client pools connections and is safe to share between threads, tasks or processes. That matters: one connection would line concurrent callers up behind a single socket, and the warm path is measured in milliseconds.

The API hangs up on a connection that has waited 30 seconds for its next request. A pooled connection older than 20 seconds is therefore dropped, not reused; and if a reused one turns out to be closed — the send fails, or the socket ends where the status line should begin — the request goes once more on a fresh connection. That is safe because nothing came back, so the request never ran. A connection that fails after the first byte of an answer, or a new connection that fails at all, is reported as a TransportError (in Elixir, kind: :transport), never retried.

Retries

Every client can resend a refused request. retries (default 0) is how many times; backoff (default 1 s) is the base wait. Only Busy and Unavailable qualify — both mean the request never ran, which is what makes sending it again safe. A handler that raised, a request the deadline killed, a missing function: sent once, never again. Each wait is the longer of the server’s Retry-After and backoff doubled per attempt (1 s, 2 s, 4 s …), and the last refusal is the error you see.

client = zygo.connect(retries=3)                 # Busy or Unavailable → wait, send again
const client = connect(undefined, { retries: 3, backoff: 0.5 });
client = Zygo.connect(retries: 3, backoff: 500)   # milliseconds

Tenants

An embedder — a product that runs its customers’ code on Zygo — has customers. A tenant is one of them, and it is what lets the API answer “whose script is this?”:

client.create_tenant("acme")                    # POST /tenants, idempotent
acme = client.for_tenant("acme")                # a view; the same connection

script = acme.put_script(source)                # registered against acme
acme.run_script("py312", script.sha256, event)  # and only acme may run it
await client.createTenant('acme');
const acme = client.forTenant('acme');
Zygo.create_tenant!(client, "acme")
acme = Zygo.for_tenant(client, "acme")         # a new struct; the same pool

Listing or creating tenants is the operator’s: a customer that could list the other customers is a leak, whatever the limits say. A tenant may read its own record, which is how a client finds out what it registered.

What a tenant gets

  • Its own scripts. A digest (the SHA-256 hash that names a script) is not a key to it — anyone holding the bytes can compute one. So a tenant naming a digest it did not register is told the script does not exist. That is the same answer an unregistered digest gets, on purpose: “it exists but is not yours” is a fact about another customer.
  • Its own cgroup. tenants/<tenant>/<function>/…, so everything one customer runs is in one place, can be killed in one write, and counted in one read. Limiting a tenant narrows it.
  • Deletion that means it. client.delete_tenant(id) stops their functions and pools and removes the scripts nothing else refers to. It answers with both lists, because neither can be rebuilt afterwards. It also takes the tenant’s tokens.

Tokens

A tenant is only worth having if the server can tell whose request this is without being told. That is what a token is: the one part of a request the caller cannot choose.

import os
import zygo_sdk as zygo

operator = zygo.connect(url, token=os.environ["ZYGO_API_TOKEN"])
minted = operator.mint_token("acme")        # POST /tenants/acme/tokens
print(minted.secret)                        # the only time this exists

acme = zygo.connect(url, token=minted.secret)
acme.put_script(source)                     # registered against acme, no header
const minted = await operator.mintToken('acme');
const acme = connect(url, { token: minted.secret });

There are two kinds, on purpose, and no finer ladder of scopes. An operator token (mint_token(), no tenant) belongs to whoever runs this Zygo: tenants, functions, pools, and more tokens. A tenant token (mint_token(id)) belongs to one customer. It registers scripts for itself, calls the pools and functions the operator declared, reads its own record — and cannot see that any other tenant exists.

What follows from that

  • The secret exists once. The server keeps only a SHA-256 of it, so client.tokens() can list every token on the host without being a way to steal one. Nothing can print a secret again: lose one, revoke it, mint another.
  • X-Zygo-Tenant is the operator’s. for_tenant(id) says which of your customers you are acting for. A tenant token already names its tenant, and a header that disagrees with it is refused, not ignored.
  • Revoking is immediate. The next request with a revoked token is a 401. The record stays, marked, so an id in a log line still points at something.
  • Deleting a tenant takes their tokens along with the scripts only they referred to.

ZYGO_API_TOKEN is the bootstrap operator token: the same variable an existing deployment already sets, with the rights it already had. On the host, zygo token mint, zygo token ls and zygo token revoke <id> do the same three things without an HTTP round trip.

Health and draining

GET /healthz needs no token, so a load balancer can probe it. It answers one of three things:

StatusCodeMeans
ok200every pool is at its floor
degraded200a pool is below min_warm; requests work, the first pay a cold start
stopping503the supervisor is draining

degraded is a 200 on purpose. A host that can serve should be served to. A probe that took hosts out of rotation for being slow would take every host out at once after a restart. stopping is the one answer that is not a 200, because a balancer that keeps sending to a draining host is the reason draining fails.

Draining

client.drain(grace=30)    # {"drained": true, "in_flight": 0}

Draining stops taking new requests, lets the running ones finish, answers, and then exits. The default grace is 30 seconds. in_flight: 0 is a clean drain; anything else means the grace ran out. A deploy script needs to know which of those happened. SIGTERM does the same, so a container stop or a systemctl restart needs no call at all. The grace there is 25 seconds, chosen against the 30 that systemd and Docker give a process before SIGKILL: a drain that outlived its own kill would never finish. Chapter 16 covers running Zygo in production.

Usage, for billing

Every finished request produces one usage event, from the supervisor — whether it came over HTTP, from zygo exec at a terminal, or from an MCP tool:

{"tenant": "acme", "function": "py312", "script": "sha256:…",
 "request_id": "00000042", "wall_ms": 812.4, "cpu_ms": 740.1,
 "peak_rss_kb": 48200, "outcome": "ok", "finished_ms": 1790000000000}

outcome is one word — ok, error, timeout, cancelled, stuck — so a dashboard groups by it instead of working it out again from four true/false fields. A caller who cancelled their own request reads cancelled, even if the deadline happened to pass while the kill landed.

Three ways to collect it

WhereHowWhich requests
The supervisor’s log, target zygo::usagenothing to set upall of them: HTTP, zygo exec, MCP
OTLPzygo api --otlp-endpoint URL: zygo.tenant.requests, .outcomes, .cpu, .wall, one series per tenantonly those that went through this API process
A webhookzygo api --usage-webhook URL: batches of up to 256, posted as {"events": [...]}only those that went through this API process

The OTLP series and the webhook are counted in the memory of the zygo api process. Requests from zygo exec or from MCP never pass through it, so they appear only in the supervisor’s log. If you bill from the webhook, make sure every billable request goes through the API.

The webhook is at least once

A batch that fails goes back on the front of the queue, in order, and is retried. So a receiver may see an event twice and should key on request_id. The queue holds at most 10 000 events. When a webhook has been down long enough to fill it, the oldest events are dropped and the count is logged. The alternative is the API process growing until it takes the serving path down to protect the billing path, which is the wrong way round. The events are in the supervisor’s log either way.

Limiting a tenant

A pool is declared once by the operator and called by every customer. One customer should not be able to take the whole of it:

client.set_limits("acme", mem="256M", cpu=0.5, pids=64, timeout="30s")

The keys are mem, cpu, pids, timeout, scratch, network and allow. It is a PATCH, so the keys you pass are set and the rest are left alone. timeout lands on the supervisor’s deadline rather than the cgroup, because a cgroup cannot enforce a wall clock. The rest are cgroup settings.

Limits only narrow

A tenant’s limits are applied as the minimum of themselves and whatever the function or pool was declared with. They are written on the request’s own cgroup before the handler is let go. So the worst a wrong value can do is give a customer less than they were promised, never more. No value and no key can widen anything, which is what makes the route safe to expose.

A value above every ceiling the tenant can reach — their own functions, and every pool on the host — is refused with 422 naming the key. It could never take effect, and storing it would let you believe you had tightened something you had not. Above one ceiling and below another is fine: it narrows the larger and does nothing to the smaller. Chapter 14 explains the limits themselves.

Secrets

A function’s secrets are delivered as files at /run/secrets/<name>, written from outside the sandbox and removed when the last request in flight finishes. An operator at a terminal supplies them from their own environment. An embedder’s customers cannot: they have their own keys and nobody to restart a supervisor. So Zygo can store secrets per tenant.

export ZYGO_SECRETS_KEY=$(zygo secrets keygen)   # before the supervisor starts
zygo secrets set acme STRIPE_KEY                 # reads it with echo off
client.put_secret("acme", "STRIPE_KEY", value)   # PUT, needs deploy rights
client.secrets("acme")                           # ['STRIPE_KEY'] — names only

A stored secret fills in what the shell did not supply, per tenant, when a function is served. Where both have a value, the shell wins: zygo serve at a terminal is somebody saying what they want now. A tenant cannot set its own secrets; the operator holds that relationship.

No way to read a value back

This is not a missing route. There is no answer shape that could carry a value, because a store that answered with values would make every route that reaches it a way to read every customer’s keys. Values are sealed with ChaCha20-Poly1305 (an authenticated cipher) under a key Zygo never stores. Each is bound to its own tenant/name, so it cannot be moved to another by anything that can only rename files. A passphrase is refused rather than stretched: turning one into a key needs a password key function, and a store that accepted hunter2 and stretched it badly would be worse than one that said no.

What that protects, and what not

It protects the bytes at rest: a backup, a stray tar, anything that can read one user’s files. It does not protect them from a process that can read the supervisor’s memory, and it is not a hardware root of trust. An operator who needs those has a KMS (a key management service).

Secrets for a runtime pool

A pool’s sandbox is shared by several tenants, and /run/secrets is one directory in it — so a pool holds no values. It names them, and each call is given the calling tenant’s values from the store, for that call only:

client.serve_runtime("py312", {"image": "python:3.12-slim", "agent": "python"},
                     secrets=["STRIPE_KEY"])            # names, not values
client.put_secret("acme", "STRIPE_KEY", value)          # acme's value
client.for_tenant("acme").run_script("py312", digest)   # reads acme's, as a file
await client.serveRuntime('py312', { image: 'python:3.12-slim', agent: 'python' },
                          { secrets: ['STRIPE_KEY'] });

The script reads /run/secrets/STRIPE_KEY as a function would. While the files exist the call has its zygote to itself, so no other tenant’s child is forked beside them; if every zygote is busy the pool grows, and at max_warm the call gets busy, which the SDKs retry. A call from a tenant that has no secret under one of the names is refused with 400 before anything runs, naming the secret; a pool that names secrets on a host with no store key is refused when it is served. The serve_runtime argument is a list of names — the difference from serve, whose secrets are values. Chapter 14 has the rules.

Files in and out

A handler that converts a document needs the document, and the caller needs what comes back. Neither belongs in a JSON event. So a request can carry a workspace: a tar archive (one file that packs many files) that Zygo unpacks into the request’s own directory. With out, the directory comes back as a tar with the answer.

  caller                                  one request's sandbox
  ──────                                  ─────────────────────
  tar of files ──inline or blob──▶ unpacked into a fresh directory under /work
                                   the handler starts in it: reads in.pdf,
                                   writes out.png
  answer + tar ◀──── out=1 ──────  the directory, packed on the way out
                                   then removed, whatever happened
tar = make_tar({"in.pdf": pdf_bytes})

out = client.run_script("convert", script, {"to": "png"},
                        workspace={"inline": base64.b64encode(tar).decode()},
                        out=True)

open("result.tar", "wb").write(out.workspace)   # already decoded
const out = await client.runScript('convert', script, { to: 'png' }, {
  workspace: { inline: tar.toString('base64') },
  out: true,
});

Inside the handler

The handler is started in its own directory and told where it is:

def handler(event):
    with open("in.pdf", "rb") as f:        # the caller's files are just here
        ...
    open("out.png", "wb").write(rendered)  # and this comes back with `out=1`
    return {"pages": 3}

Send a fixture once: blobs

An embedder often sends the same fixture across a thousand calls. Store it once as a blob and name it by digest:

blob = client.put_blob(tar)                       # PUT /blobs, idempotent
client.run_script("convert", script, event, workspace={"blob": blob.sha256})

A warm function’s body is the event itself, with nowhere to put a workspace. So there it goes in the query string: client.call(name, event, workspace=blob.sha256, out=True), which is ?workspace=sha256:…&out=1. It takes a blob only, because an inline tar in a URL would be a megabyte of base64 in a request line. The async Python client takes both as well.

What keeps one request’s files from another’s

Not a mount namespace, and the reason was measured rather than assumed. A forked child runs at an unprivileged uid with no CAP_SYS_ADMIN in the sandbox’s user namespace, so unshare(CLONE_NEWNS) fails with EPERM. That holds even with the most permissive seccomp profile, so it is the namespace and not a filter. One path cannot mean a different directory to each request. What is there instead is listed plainly, because the first two are weaker than a namespace would be:

  • /work cannot be listed (mode 0311), so a request cannot see its neighbours’ names. The test suite checks this by trying.
  • The directory’s name is 128 random bits, not the request id, which is a counter.
  • It is removed when the request ends, whatever the request did, so the window is one request long. This is also checked.

Archives are unpacked, not trusted

Every archive is unpacked by Zygo under strict rules. Only files and directories are allowed: no symlinks, no hard links, because that is how an archive writes outside the directory it was unpacked into. No .., no absolute paths. File modes are Zygo’s, not the archive’s. Entries and bytes are capped, and counted as they are written, not read from a header an archive is free to lie in.

The way out is careful too. ?out=1 packs what the handler left, and the handler may leave a process running that keeps changing it. So Zygo never follows a path into the folder. It opens each folder it has already opened, one name at a time, and never follows a link. A name that has become a link, a pipe or a socket since the folder was read is skipped. A workspace folder swapped for a link is not packed at all. Only files and folders come back, and never more bytes than the cap.

Watching a request

A call that takes a minute has something to say before it finishes:

for event in client.stream("render", {"pages": 400}):
    if event.is_result:
        print(event.result.result)
    else:
        print(event.kind, event.data, end="")      # stdout, stderr, progress
for await (const event of client.stream('render', { pages: 400 })) {
  if (event.kind === 'result') console.log(event.result.result);
  else process.stdout.write(event.data);
}

Each item is a piece of the request’s output, and the last one is the result: exactly what the non-streaming call would have returned, or raised. A handler that printed and then failed produced both. So the output is delivered first, and the exception comes when you iterate past the result.

Three kinds of item

stdout and stderr are what the request’s process wrote, kept apart as everywhere else. progress is its own kind, not a line of stdout. A long request has two things to say — what it printed, and how far it has got — and a caller should not have to parse a handler’s log messages to find the second. The handler calls event.progress(...). It exists whether or not anybody is listening, so a handler does not break depending on who called it:

def handler(event):
    for n, page in enumerate(event["pages"]):
        event.progress(f"{n} of {len(event['pages'])}")
    return {"done": True}

The result still carries the whole of stdout and stderr, bounded as always. A caller that streamed and one that did not see the same text; streaming only changes when.

On the wire

  POST /fn/render?stream=1          Content-Type: application/x-ndjson
  ──────────────────────────────────────────────────────────────────────────
  {"stream": "stdout",   "data": "loading\n"}
  {"stream": "progress", "data": "1 of 400"}
  {"stream": "stderr",   "data": "warning: …\n"}
  …
  {"status": 200, "result": {…}, "request_id": "…", "stdout": "…", …}   ◀── last line

Streaming is per request, not per function. Sending each print() as it happens costs a system call per print() on a path measured in milliseconds. So a caller that wants to watch pays for it, and everybody else keeps the fast shape. The answer is newline-delimited JSON (one JSON object per line) rather than server-sent events: every language can read a line and parse JSON. The streaming connection is held for the whole request and is not pooled. Abandoning the iterator closes it, which does not cancel the request — pass a key and use cancel for that.

Cancelling a request

A request that is running can be stopped:

out = client.call("render", event, key="job-4711")   # name it on the way in
...
client.cancel("job-4711")                            # from anywhere
const controller = new AbortController();
const call = client.call('render', event, { signal: controller.signal });
controller.abort();                                  // cancels it on the server too

Asynchronous Python needs no key at all:

task = asyncio.ensure_future(client.call("render", event))
task.cancel()          # sends the cancel before CancelledError propagates

The caller of the cancelled request gets Cancelled (HTTP 499), which is on purpose not Timeout. A timeout says the work is too slow or the limit too tight; a cancel says the answer stopped being wanted. Both arrive as exit 137 from the kernel, and only the side that sent the signal can tell them apart, so the supervisor records which it was.

  caller A ── POST /fn/render  (X-Zygo-Request-Key: job-4711) ──▶ running …
  caller B ── DELETE /requests/job-4711 ──▶ supervisor
                                              └──▶ writes cgroup.kill on that
                                                   request's own cgroup
  caller A ◀── 499 Cancelled

A key, not the request id

The id is assigned by the host and arrives with the answer, which is too late to stop the call it belongs to. X-Zygo-Request-Id comes back on every response and in the body as request_id; it joins a log line to its request. The key is what a caller uses to name a request it is still waiting for. Reusing a key is allowed, and one cancel then stops every call under it.

What actually stops the work

The supervisor writes cgroup.kill on the request’s own cgroup, from outside the sandbox. That reaches everything the handler started, and does not depend on the tenant’s code being in a state where a signal helps. The agent inside is told, so it can mark the answer, but an agent that ignores the message changes nothing. A cancel that arrives before the request was let go is the best case: nothing of the handler has run, and started: false in the answer says so. Cancelling something already finished, or another tenant’s request, is the same NotFound. Request ids are a counter, not a secret, so ownership is what keeps a cancel honest.

Runtime pools

A warm function is one script in one zygote (the parked, ready process that Zygo forks per request; see chapter 13). A runtime pool is the other shape: an image, a dependency set and an agent, with no code in it. The script arrives with the call.

  WARM FUNCTION                          RUNTIME POOL
  ┌───────────────────────────┐          ┌───────────────────────────┐
  │ image + deps + agent      │          │ image + deps + agent      │
  │ + one handler, imported   │          │ no code                   │
  └───────────────────────────┘          └───────────────────────────┘
  event ──▶ handler(event)               script digest + event ──▶ the forked
                                         child loads that script, runs it
client.serve_runtime("py312", {                 # POST /runtimes
    "image": "python:3.12-slim",
    "agent": "python",
    "min_warm": 2, "max_warm": 8,
    "mem": "512M", "timeout": "60s",
})

script = client.put_script(source)              # once, PUT /scripts
out = client.run_script("py312", script.sha256, {"month": "2026-09"})
print(out.result, out.metrics.wall_ms)
await client.serveRuntime('py312', { image: 'python:3.12-slim', agent: 'python' });
const { sha256 } = await client.putScript(source);
const out = await client.runScript('py312', sha256, { month: '2026-09' });
Zygo.serve_runtime!(client, "py312", %{"image" => "python:3.12-slim", "agent" => "python"})
%Zygo.Script{sha256: sha256} = Zygo.put_script!(client, source)
out = Zygo.run_script!(client, "py312", sha256, %{"month" => "2026-09"})

How a script gets in

client.runtimes() lists the pools with their zygote counts, and client.stop_runtime(name) stops one (so does zygo stop <name>). A pool can name secrets; each call then gets the calling tenant’s values — see Secrets for a runtime pool. run_script also takes the source directly, for a one-off not worth registering. The request never reaches the zygote: the supervisor writes the script into the sandbox, and the forked child loads it after the child’s seccomp filter (its list of allowed system calls) is installed. That is what makes it safe for two tenants to share a pool. It is checked, not just claimed: tests/linux/verify_api.sh asks a script where it was loaded from and whether it can list what else is in flight.

A pool with no agent

A pool usually holds an agent — an interpreter, warm, forking per request. It does not have to. Give it a cmd and no agent and you get the warm-exec shape. Zygo holds the sandbox, writes each request’s script into it, and runs cmd with that path as its last argument and the event on stdin.

client.serve_runtime("sh", {"image": "alpine:3", "cmd": ["/bin/sh"]})
script = client.put_script(open("wordcount.sh").read())
out = client.run_script("sh", script.sha256, {"text": "warm exec in sh"})

That runs sh /run/script/<digest> inside the sandbox. It is the right shape for bash, for a static binary that takes a script as an argument, and for any language that starts in under a millisecond. There is nothing for an agent to save there, and the warm protocol would only be one more moving part.

What warm-exec does not have

There is no protocol to carry them, so warm-exec has no streaming, no progress(), no workspaces, and no per-tenant limits narrowed per request. The sandbox itself is the same: same namespaces, same seccomp profile, same cgroup per request, same deadline. examples/warm-exec/ has both shapes side by side.

Dependency sets

A pool’s requirements names a file on the Zygo host, which is the one thing an embedder does not have. POST /deps is the other half: send the lock file itself, and get back an id a pool can be built on.

deps = client.put_deps("python:3.12-slim", {        # POST /deps
    "requirements.txt": open("requirements.txt").read(),
})
deps.id        # 'deps_3f1c…' — its name from now on
deps.state     # 'building'

while client.deps(deps.id).building:                # GET /deps/<id>
    time.sleep(2)

client.serve_runtime(
    "py312",
    {"image": "python:3.12-slim", "agent": "python"},
    deps=deps.id,
)
const deps = await client.putDeps('node:22-slim', {
  'package.json': manifest,
  'package-lock.json': lockfile,      // `npm ci` needs it, so this does too
});
await client.serveRuntime('node22', { image: 'node:22-slim', agent: 'node' },
                          { deps: deps.id });

Python takes a requirements.txt and gets a venv (a private folder of installed packages). Node takes a package.json and its lock file and gets npm ci. Either way the result is mounted read-only at /venv and the environment points at it — PATH for Python, NODE_PATH for Node — so a script just imports what the lock file named. Chapter 15 covers dependencies in general.

  POST /deps ──▶ 202 building ──┬──▶ ready ──▶ serve_runtime(…, deps=id)
                                │              └─▶ mounted read-only at /venv
                                └──▶ failed ──▶ deps(id).log has the reason

Five things worth knowing

  • It answers before the build finishes. A pip install takes minutes, and an HTTP request that waited would time out in every proxy on the way. Poll deps(id), or send the serve_runtime and retry the Unavailable it raises, which carries the host’s Retry-After as retry_after — a client opened with retries= does that itself.
  • A pool named against a build still running is refused, not queued. Nothing is started. A zygote warmed without the dependencies it was promised would serve requests that fail at import.
  • The id is a hash of the files and the image. The same lock file on the same image is one build however many customers send it, and all of them see it in deps(). A different image is a different id, because a wheel built for one interpreter fails in another.
  • The build reaches the package registries and nothing else. Installing a package runs its code — a setup.py, an npm lifecycle script — and the lock file came from whoever holds a token. The build sandbox has network = "egress" with only the registries allowed. A host that cannot enforce that (no passt, no nftables) refuses the build rather than falling back to host networking.
  • A failed build keeps its log, on the same object as the state: client.deps(id).log is the resolver’s own words.

client.delete_deps(id) forgets one, and is refused while a pool is built on it. The pool holds a read-only mount of that directory, and removing it under a warm zygote would make its imports fail one at a time.

The script store

An embedder’s scripts live in the embedder’s database, not on the Zygo host. PUT /scripts is how one gets to a sandbox without a file on the host or a line in sandbox.toml:

script = client.put_script(source)      # PUT /scripts, body is the script
script.sha256                           # 'sha256:71e2b5…' — its name from now on
script.existed                          # the store already had exactly these bytes

The body is the script itself, not JSON around it: a script is a file, and wrapping its bytes only to unwrap them again helps nobody. The name is the SHA-256 of those bytes. That makes the call idempotent (safe to repeat) in the strongest sense: the same script from two tenants is one file on disk, and neither can put different bytes under a digest the other is running.

Looking up and forgetting

client.script(digest) says whether the host holds it and how big it is. It never returns the bytes: a digest is not a key, so a store that answered with the script would make every tenant’s code readable by anyone who could guess it. client.delete_script(digest) forgets one, and needs deploy rights, because forgetting it forgets it for every tenant that sent the same bytes.

Why PUT /scripts is not gated

Registering a script does not make it runnable on its own. A request still has to name something to run it in, which is a runtime pool the operator declared. So any authenticated caller may register a script, tenant tokens included. A tenant registering its own code runs nothing by doing so, and the digest is theirs from then on.

Errors in the clients

Each kind of failure is its own type, because each one means something different about what to do next.

HTTPPython and Node errorElixir kindMeansWhat to do
429Busy, with retry_after:busythe pool is full; the request never ranretry after retry_after
408Timeout:timeoutthe deadline killed the requestthe work is too slow, or the limit too tight
499Cancelled:cancelledsomebody stopped the requestnothing: this is what was asked for
504Stuck:stuckthe sandbox went quiet with budget leftlook at the function, not its timeout
500 from a handlerHandlerError, with stdout, stderr, exit_code:handlerthe handler raisedfix the function
404NotFound:not_foundno function (or script, or request) by that nameserve it, or check the name
401, 403AuthError:authwrong token, or a deploy call without deploy rightscheck the token or the flag
400SpecError:specthe sandbox as described cannot be resolvedfix the request
no connectionTransportError:transportthe API could not be reachednothing ran
503Unavailable, with code and retry_after:unavailabledependencies still building (deps_building), a zygote that failed to warm (warm_failed), or the API stopping; the request never ranretry after retry_after, or let retries do it
anything elseZygoError:othere.g. 413, 422; the message has the statusread the message

Unavailable is the 503: the host cannot do this yet, and nothing about the request needs changing. Unlike Busy it is not backpressure — the wait is for something the host is doing. health() raises it once the API is stopping. A 422 (a limit above every ceiling) is still a plain ZygoError. Every error type is a subclass of ZygoError, so catching it catches everything. In Elixir every one is a Zygo.Error, returned as {:error, _} or raised by the ! functions, with the same fields as the class it stands for — retry_after, code, stdout, stderr, exit_code, request_id.

Busy or HandlerError: the one that matters

Busy means the request was refused before anything happened, so retrying is correct. A handler that raised will raise again. Timeout is not a guess either. The supervisor records that it killed the request, because a deadline kill and an out-of-memory kill both arrive as exit 137. In all, exit 137 has four readings — a deadline, an out-of-memory kill, a cancel, and a sandbox that went quiet — and the supervisor is the only side that knows which, so it says.

Long requests

A function’s timeout may be hours, and a caller may wait up to a day (X-Zygo-Timeout-Ms, at most 24 hours; the default wait is 60 s). Two things make that safe rather than a way to hold a slot for ever:

  • A heartbeat. The agent says every second or two that a request is still alive. A request the supervisor has heard nothing about for a minute is killed and raises Stuck, whatever its budget said. So a wedged request costs a minute, not its whole timeout, and “your code is slow” stays different from “the sandbox stopped answering”.
  • The idle policy leaves working zygotes alone. A zygote nobody has called for idle_timeout is frozen. One with a request in flight is not, whatever the clock says, because freezing it would stop the request. A request that runs for an hour under a two-second idle_timeout still finishes.

A batch is the exception to raising: each element is a result or an error, returned rather than raised, because one refused event must not hide the answers to the others.

A worked example

examples/plugin-host/ is a plugin host in about a hundred lines, built on this API alone: no sandbox.toml, no file on the Zygo machine, no shelling out to zygo. It onboards customers and gives each their own token, limits and secrets. It declares one runtime they all share, installs their code by digest, runs it with files in and out, streams the long ones, stops them, and offboards. make verify-plugin-host runs it against a real kernel. That includes the two checks this whole layer exists for: one customer cannot run another’s plugin by naming its digest, and a customer is held to their own memory limit rather than the runtime’s.

The same host from Node is examples/plugin-host-node/: a node:http server that onboards customers with an operator token, registers and runs their scripts through forTenant, streams one route as NDJSON, and turns HandlerError, Timeout, Cancelled, NotFound, Busy and Unavailable into its own status codes. node --test runs it against a fake Zygo; make verify-plugin-host-node against a real one.

The OpenAPI document

zygo api --openapi > openapi.json

This prints OpenAPI 3.1 for the build that printed it; spec/openapi.json in the repository is the committed copy. info.version is Zygo’s release. x-zygo-api is the surface version — the one a client checks. It moves only when an operation is removed or renamed. Adding a route does not move it, because an older client does not call a route it has never heard of.

What it covers, and what it does not. It lists every route with its path parameters, who may call it (x-zygo-who), whether it needs deploy rights (x-zygo-deploy), the bearer scheme (none for GET /healthz) and the shape of an error. It does not describe request bodies, query parameters, headers, or any status but 200. Those are in the tables at the top of this chapter. A client generated from the document gets one method per route and no types for what goes in, so write the bodies from this chapter, or use one of the SDKs.

The document is hand-written, and three tests read the router’s own source to keep it true: one fails when a route is missing from the document, one when the document names a route that is gone, and one when a route answered without a token is not marked so. Each SDK has a test of its own, over every operation but GET /metrics, which is for a scraper rather than a client. A route added without a client method is a test failure rather than something an embedder finds later.

Versioning

GET /version reports:

FieldMeaning
versionThe Zygo release.
apiThe HTTP surface. Bumped only on an incompatible change to a route, so it stays put across releases that change what happens behind them. This is what a client checks.
controlThe CLI-to-supervisor protocol, which no SDK speaks. Reported because a mismatch there explains an API that is up but answering errors.
deployWhether this caller has deploy rights — the truth about your own token, not just the flag.
private_netWhether what this caller deploys may allow private addresses: zygo api --allow-private-net, and deploy rights.

All three packages are 0.1.5 and follow the repository’s 0.x policy: the shape may change with a release note. After 1.0 it will not change without a major version.

Running against a real Zygo

export ZYGO_API_TOKEN=$(head -c 32 /dev/urandom | base64)
zygo up                            # warm what sandbox.toml declares
zygo api --allow-deploy &          # 127.0.0.1:7700

python -c "import zygo_sdk as zygo; print(zygo.connect().functions())"
elixir -e 'Mix.install([{:zygo_sdk, "~> 0.1"}]); IO.inspect(Zygo.functions!(Zygo.connect()))'

For one customer rather than the whole host:

zygo token mint --tenant acme      # prints the secret, once

The SDK test suites do not need any of that. All three run against a stand-in API and check the client: the transport, the error mapping, the connection pool. A test that needs a real sandbox belongs in the Rust suites, against a real kernel. The Elixir suite also has one read-only check against a real API, which runs only when asked: ZYGO_LIVE=1 mix test --only live in sdk/elixir, against the API that ZYGO_API_URL names.

make test-sdk

Part three: the MCP server

zygo mcp gives an agent host a code interpreter whose boundary is a file somebody reviewed. It speaks the Model Context Protocol (MCP), the standard way an AI agent host talks to tool servers, over standard input and output. That is what Claude Code, Claude Desktop, Cursor and the rest start as a child process. There is no port, no token and no network: the transport is a pipe between two processes running as the same user.

{ "mcpServers": { "zygo": { "command": "zygo", "args": ["mcp"] } } }

That is the whole installation. On macOS it is forwarded into the Linux VM like every other sandbox command, and the VM hop is paid once, when the host starts the server, rather than once per tool call.

Two hosts have a command for it instead of a file:

# Claude Code, for this project; add --scope user for every project
claude mcp add zygo -- zygo mcp --mem 512M --timeout 60s

# Codex: in ~/.codex/config.toml
#   [mcp_servers.zygo]
#   command = "zygo"
#   args = ["mcp", "--mem", "512M", "--timeout", "60s"]

The flags after mcp are the ceiling the model works under — the next section explains why they belong to whoever installs the server.

  ┌────────────┐  stdin/stdout, JSON-RPC  ┌──────────┐      ┌────────────────────────────┐
  │ agent host │◀────────────────────────▶│ zygo mcp │─────▶│ run_code: a fresh one-shot │
  └────────────┘                          └────┬─────┘      │ sandbox for every call     │
                                               │            └────────────────────────────┘
                                               │ list_functions, call_function,
                                               │ function_logs
                                               ▼
                                     an existing supervisor (warm functions)

The rule that shapes it

A model reads untrusted text — a web page, a file, an error message — and that text can ask it for things. So the tools expose a program and nothing else: no image, no mounts, no network mode, no limits. Those are set once, on the command line, by the person who installed the server.

zygo mcp --mem 512M --timeout 60s --workspace ./agent-scratch
zygo mcp --net egress --allow api.github.com:443
zygo mcp -f ./sandbox.toml              # [defaults] becomes the ceiling

A model that needs more than this does not get to ask for it. Somebody declares a function in sandbox.toml — with its dependencies, its egress allow list and its secrets — and the model calls that by name. The boundary is then in a file that was reviewed, which is where it belongs. A test enforces this: no_tool_can_widen_the_sandbox fails if any tool schema ever grows an image, mount, network, mem or similar field. Adding one looks harmless on its own, which is exactly why it is checked.

The flags

FlagDefaultWhat it sets
--workspace DIRa scratch folder, removed on exitthe host folder mounted at /work
--python-imagepython:3.12-slimthe image for language: python
--node-imagenode:22-slimthe image for language: node
--sh-imagealpine:3the image for language: sh
--mem, --cpu, --pids, --timeout, …the resolver’s defaultsthe limits, as for zygo run
--net, --allow, and the other sandbox flagsno networkthe sandbox, as for zygo run
-f FILE—a spec file; its [defaults] becomes the ceiling

The tools

ToolParametersWhat it does
run_codelanguage (python, node, sh), code, stdin?Runs the code in a fresh sandbox, with /work as its folder; returns output and, in words, why it failed.
list_functions—The warm functions and their state.
call_functionname, event?Calls one by name with a JSON event (fixed 60 s limit).
function_logsname, limit? (1–200), failed?Its recent log, or only the failures.

There are no tools for pools, scripts or tenants. call_function runs with no tenant, as the host’s own call. A limit outside 1–200 is clamped into that range rather than refused. The server’s instructions tell the model to prefer a warm function over run_code where one exists: a millisecond against tens of them, with dependencies already imported.

How run_code runs a program

The code is written to a file, /zygo/main.py, /zygo/main.js or /zygo/main.sh, in a folder mounted read-only. It is then run with python3, node or /bin/sh. So the program can be of any length, tracebacks name a real file, and the code cannot rewrite itself mid-run. stdin, if given, is fed to the program and then closed. The outer deadline is the sandbox’s timeout (30 s by default) plus 300 s, which leaves room for an image pull on the first run; the sandbox’s own timeout is still enforced on the whole process tree.

/work persists between calls

/work is a writable directory and the program’s working directory. It persists between calls, so a model can write a file in one call and read it in the next. Everything else written is thrown away when the call ends. Without --workspace it is a scratch directory removed when the server exits; naming one makes it real, and is how an agent is given a project to work on.

Failures in words

When a sandbox is killed, the tool result says why in words: “ran out of memory” and “exceeded its time limit” are different sentences. Both are exit 137, and a model told only “exit 137” cannot know which of its two problems to fix.

What a sandbox gets

Whatever zygo run gives. On the ns backend that is: no capabilities, a read-only root with pivot_root and a masked /proc, the default seccomp allowlist of about 215 system calls (190 of them exist on aarch64), Landlock where the kernel has it, required memory, CPU and process limits, and no network at all unless the command line said otherwise. Chapter 23 says where that boundary is weaker than it looks. run_code is not sandboxed from the model — running the model’s code is the point. It is sandboxed from the machine.

Concurrency and failures

The server speaks JSON-RPC 2.0, a simple format where each request is a JSON object with a method name and an id. It knows four methods: initialize, ping, tools/list and tools/call. Notifications (messages with no id) are ignored. Each request is handled on a thread of its own, so a run_code that takes thirty seconds does not block a list_functions behind it. Answers are written one line at a time, so two cannot mix.

ProblemAnswer
A line that is not JSONJSON-RPC error -32700 (parse error)
An unknown method-32601
Bad parameters for a method-32602
A tool that ran and faileda normal result marked isError

A tool that fails answers with a result marked isError, not a JSON-RPC error. The difference matters: an error at the protocol layer is handled by the host and never reaches the model, and the model is the one that could fix a traceback. The three tools that read warm functions connect to an existing supervisor and do not start one. There is nothing to read or call unless somebody has already served something.

Protocol revisions

The server speaks 2025-06-18 (its preferred one), 2025-03-26 and 2024-11-05. It answers initialize with the revision the client asked for when it is one of those, and with its preferred one otherwise. So the client decides whether it can live with the answer, rather than the server agreeing to a dialect it does not know.

Trying it without a host

The transport is a pipe, so a shell is enough:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | zygo mcp

Two lines of JSON come back, one per request. Standard output belongs to the protocol, so anything a human should read — including the workspace path — goes to standard error, which is where a host shows a server’s log. Chapter 18 goes further with agents.

What is not built

To say it plainly in one place:

  • Streaming, progress, workspaces and per-request tenant limits in warm-exec pools (a pool with cmd and no agent).
  • Per-tenant series in /metrics, and billing counts for zygo exec and MCP requests outside the supervisor log.
  • Pool, script and tenant tools in MCP.
  • A warm function’s code sent by value. PUT /fn/{name} names its entry, requirements and mounts as paths under base_dir, on the host the API runs on. One warm zygote per script version (chapter 13) is therefore for a caller that shares a disk with Zygo. A caller on another machine sends its code with each request, to a runtime pool.
  • Request bodies, query parameters and status codes in the OpenAPI document (above).

18. Writing an agent

An agent is the small program inside a warm sandbox that loads a handler once and gives each request a process of its own. Zygo ships one for Python and one for Node. This chapter is for people who want to write one for another language: it explains the wire protocol in plain words, how to test an agent with zygo agent test, and the mistakes that cost agents their speed.

What an agent does

A warm function keeps a sandbox up and pays for each request with a fork() — a system call that makes a copy of a running process — rather than with a new container (chapter 13). The agent is the part that does the forking. It talks to the supervisor, Zygo’s process on the host that owns every warm sandbox, over a socket (a two-way channel between two processes). The protocol is language independent: an agent in any language that can open a socket and fork can serve warm functions at the same speed. Anything that speaks it gets resource limits, request deadlines, idle pausing, metrics, secrets and the vm transport without knowing they exist.

  host                                 │  inside the sandbox
                                       │
  ┌──────────────┐   socket (fd 3)     │  ┌──────────────────────────────┐
  │  supervisor  │◀────────────────────┼─▶│ agent (the zygote)           │
  │              │   frames: READY,    │  │ runtime started, handler     │
  │ cgroups,     │   EXEC, FORKED,     │  │ loaded, never runs a request │
  │ deadlines,   │   GO, DONE, ...     │  └──────┬───────────────────────┘
  │ secrets      │                     │         │ fork, per request
  └──────────────┘                     │  ┌──────▼───────────────────────┐
                                       │  │ child: runs handler(event)   │
                                       │  │ once, sends RESULT, exits    │
                                       │  └──────────────────────────────┘

Do you need one?

You need an agent only when starting your runtime is slow enough to be worth doing once. Without an agent Zygo uses warm-exec: the sandbox is held open and each request is a fresh process running your cmd, with the event on standard input and JSON expected on standard output. That works for every image and every language, and needs no code from you at all. It costs a median of about 1.4 ms a request, measured on a Lima VM on an Apple M1 Max (chapter 25). An agent is what you write when starting your runtime and importing your libraries costs more than that: a Python interpreter with its imports, a JVM, a Node process with a large dependency tree.

[fn.parse]
image  = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"]
cmd    = ["/app/parse"]     # the event on stdin, JSON on stdout

If your language starts in a millisecond, use cmd and stop reading.

How messages travel

Every message is one frame: a 4-byte length, then that many bytes of JSON. The length is an unsigned 32-bit number in big-endian order (most significant byte first) — struct.pack(">I", n) in Python, Buffer.writeUInt32BE in Node. The body is UTF-8 JSON: an object with a type field that names the message.

  ┌────────────────────┬──────────────────────────────────────────────┐
  │ length: 4 bytes    │ body: `length` bytes of UTF-8 JSON           │
  │ uint32, big-endian │ {"type":"EXEC","id":"01f3","event":{...}}    │
  └────────────────────┴──────────────────────────────────────────────┘
BackendTransport
ns, gvisoran AF_UNIX stream socket (a local socket that is a file), mode 0600
vmvsock, a socket between a virtual machine and its host

A frame may be at most 32 MiB. A larger payload belongs in a file, not on the control socket. This cap protects the supervisor: the agent runs untrusted code, and a frame that claims to be 4 GiB must not make anyone allocate 4 GiB. So check the announced length before allocating. A connection closed between frames is a clean shutdown; one closed in the middle of a frame is an error, so an agent that dies mid-write does not look like an orderly exit.

The messages

There are a handful of messages. The core set is enough for a working agent; the others are optional additions that an agent may ignore.

MessageDirectionWhat it is forSince
READYagent → supervisor“Warm-up is done.” Sent once, with proto, the agent’s pid, imports_ms, rss_kb and a runtime name.1
EXECsupervisor → agentOne request: an id, the event, timeout_ms, and optional env_overrides.1
FORKEDagent → supervisor“The child for this request exists, with this pid, and is waiting.”1
GOsupervisor → agent“The child is in its cgroup now; it may start.”1
RESULTchild → agentThe handler’s answer: exit_code, result or error, stdout, stderr, and metrics.1
DONEagent → supervisorThe RESULT, passed on without dropping a field.1
PING / PONGboth ways“Are you alive?” “Yes.” An agent that stops answering is restarted.1
SHUTDOWNsupervisor → agentFinish the requests in flight within grace_ms, then exit.1
ERROReither wayA protocol failure, with a code (see Errors).1
EXEC.scriptsupervisor → agentThe code to run arrives with the request (runtime pools).1.1
CANCELsupervisor → agent“Somebody asked to stop this request.”1.2
CHUNKchild → agent → supervisorA piece of output, sent while the request runs.1.3
PING with idagent → supervisorA heartbeat: “this request is still running.”1.4
EXEC.workspacesupervisor → agentThe folder with this request’s files.1.5

READY may also carry child_filter, which says how the agent honours the strict child filter: seccomp, a name for an equivalent, or none. It is for diagnostics only. spec/protocol.md is the full, normative text, with every field.

One request, step by step

The agent says READY. The supervisor sends EXEC. The agent forks and answers FORKED with the child’s pid. The supervisor moves that pid into a new cgroup for this request and sends GO. The child runs the handler, sends RESULT to the agent and exits, and the agent passes it on as DONE.

  supervisor                  agent                       child
      │                         │                           │
      │◀──────── READY ─────────│                           │
      │                         │                           │
      │───────── EXEC ─────────▶│                           │
      │                         │───────── fork() ─────────▶│
      │◀──────── FORKED ────────│                           │ (waiting)
      │  [move pid to cgroup]   │                           │
      │────────── GO ──────────▶│──────────────────────────▶│
      │                         │                           │ handler(event)
      │                         │◀──────── RESULT ──────────│
      │                         │                           │ _exit(0)
      │◀──────── DONE ──────────│
      │  [remove cgroup]        │

Why FORKED and GO exist

The FORKED/GO handshake is the part worth understanding. A cgroup is the kernel’s way to limit and count the memory and CPU of a group of processes (chapter 3). A new child starts in the agent’s cgroup. The handshake lets the supervisor move it into the request’s own cgroup before the request runs. That is what makes a per-request memory limit possible, and a deadline that kills the whole process tree. So the child must do nothing before GO: anything it allocated would be billed to the agent and escape the request’s limits.

Secrets need nothing from you

Secrets are deliberately not in EXEC. The supervisor writes each one as a file, /run/secrets/<NAME>, from outside the sandbox, between FORKED and GO, and removes it when the last request in flight finishes. The agent never sees a value, so it cannot leak one: it is not in EXEC, not in the zygote’s memory, and not on this connection. An agent must not try to handle secrets. The files are there before GO and are the child’s to read.

Results, errors and a child that dies

RESULT needs only type, id and exit_code; everything else is optional. error, with a human-readable traceback, is present when the handler raised, and result is then meaningless. Metric fields such as peak_rss_kb, wall_ms and cpu_ms sit at the top level, not nested. If the child dies without a RESULT — killed for memory, killed at its deadline, or crashed in a C extension — the agent makes up a DONE with a non-zero exit_code and an error that describes the death. Silence is never an answer: the supervisor has a request waiting on it.

{"type":"RESULT","id":"01f3","exit_code":0,"result":{"status":200},
 "stdout":"…","stderr":"","peak_rss_kb":41200,"wall_ms":12.3,"cpu_ms":9.1}

Errors

ERROR is a failure of the protocol, not of the handler. A handler that raised is a DONE with a non-zero exit_code. A child that could not run the request at all may answer ERROR instead of RESULT, and the agent passes that on with the request’s id instead of a DONE. Either way there is exactly one answer per EXEC.

CodeMeaning
bad_messageThe frame could not be parsed, or is not valid right now.
unsupported_versionThe proto is not supported.
handler_loadThe handler could not be imported (the agent is unusable), or a script’s bytes do not match its digest.
spawn_failedfork(), or the spawn fallback, failed.
timeoutThe request ran past timeout_ms.
overloadedToo many requests in flight.
bad_resultThe result could not be turned into JSON.
internalAnything else; see message.

Streaming output: CHUNK (1.3)

An EXEC with "stream": true asks to watch the request. The child then sends CHUNK frames as it writes, each with a stream (stdout, stderr or progress) and the data. Streaming is asked for per request, not per function: a chunk per print() is a system call per print(), so only a caller that wants to watch pays for it. An EXEC that did not ask carries no stream field at all, and must produce no CHUNK.

  supervisor                  agent                       child
      │──── EXEC{stream} ──────▶│                           │
      │◀──────── FORKED ────────│                           │
      │────────── GO ──────────▶│──────────────────────────▶│ handler(event)
      │                         │◀──────── CHUNK ───────────│   print(…)
      │◀──────── CHUNK ─────────│                           │
      │                         │◀──────── RESULT ──────────│
      │◀──────── DONE ──────────│

Three rules make streaming honest. The agent passes chunks on without buffering; one that collected them would deliver the bytes at the same moment as RESULT. A chunk is not cut at line ends: half a line written before the handler blocks should arrive. And RESULT still carries the whole of stdout and stderr, so a caller that streamed and one that did not see the same text. progress is not a line of stdout: the reference agents give the handler a progress() call on the event, present whether or not anyone is listening. There is no sequence number; frames of one request travel in order on one connection.

Cancelling a request: CANCEL (1.2)

CANCEL says someone asked for a request to stop. It is not what stops it. The supervisor kills the request by writing cgroup.kill on the request’s cgroup from outside the sandbox. That takes the child and everything it started, and does not need the handler to be somewhere a signal helps. The frame is for the answer: a cancel, a deadline and an out-of-memory kill all look like signal 9 and exit 137. An agent that gets CANCEL sets "cancelled": true on that request’s DONE, so the caller can tell its own cancel from a limit it needs to raise.

  supervisor                  agent                       child
      │───────── EXEC ─────────▶│───────── fork() ─────────▶│
      │◀──────── FORKED ────────│                           │ (waiting)
      │────────── GO ──────────▶│──────────────────────────▶│ handler(event)
      │                         │                           │
      │──────── CANCEL ────────▶│ (marks the request)       │
      │  [write cgroup.kill]    │                           X
      │◀──── DONE{cancelled} ───│

An agent that implements CANCEL must not treat an unknown id as an error: the request may have finished just before the frame arrived. An agent that does not know the message answers ERROR / bad_message and carries on; the kill still lands, and the supervisor fills in cancelled itself. A CANCEL that arrives before GO is the best case: Zygo kills the child and never sends GO, so not one line of the handler ran. That is why DELETE /requests/<id> can report whether the work had started.

Heartbeats: PING with an id (1.4)

While a request runs, the agent may send PING with that request’s id, without waiting for an answer. Zygo’s timeout can be as long as a day. Without a heartbeat, a request stuck in the first minute of a six-hour budget would hold its slot for the rest of it. A supervisor that hears nothing about a request for its grace period (a minute, in Zygo) kills it as stuck, which is a different answer from “too slow”. The reference agents send one every two seconds. A CHUNK counts as a sign of life too. Do not skip the heartbeat because the child looks idle: an agent cannot tell a child that is computing from one that is blocked.

{"type":"PING","seq":0,"id":"01f3"}

Scripts that arrive with the request (1.1)

For a runtime pool (chapter 13), the agent starts with no handler, and each EXEC carries a script. This lets one warm zygote serve thousands of scripts; a warm zygote costs about 11 MB of memory, so ten thousand of them would need about 109 GiB.

FieldMeaning
pathWhere the supervisor put the file in the sandbox before GO: mode 0400, in a read-only folder that cannot be listed.
sourceThe script itself, on the wire.
digestsha256:… of the contents. The child checks it before loading.
entry_pointWhat to call. Default handler.

At least one of path and source is set, and the supervisor sends path whenever it can. The reason is memory: a source passes through the agent, and every later child — perhaps another tenant’s — forks from the agent’s memory. With path, only the child ever holds the bytes. Paths end in the digest, so two tenants with identical bytes share one file, and nobody can swap in different bytes under a digest someone else is running.

  EXEC{script: path + digest}
      │
      ▼
  child (after GO) ─▶ install the child filter ─▶ read the bytes ─▶ hash them
                                                                       │
                     digest matches? ── no ──▶ ERROR / handler_load  ◀─┘
                          │
                         yes ─▶ load the script ─▶ call entry_point(event)

Rules for scripts

  • The child loads the script, after GO. Never the agent: a zygote that imported a tenant’s script would pass it on to the next request, which may be someone else’s.
  • After the child filter too (see The strict child filter). A script’s top-level code is request code. An agent that loads it first and filters afterwards gives a strict pool nothing.
  • The digest is not advisory. Hash the bytes you are about to load — not the file read a second time, which gives a tenant a moment to change it — and refuse a mismatch with ERROR / handler_load before any of it runs. The same rule covers source.
  • What a script prints while it loads belongs to the request, so it goes in that request’s stdout, not the agent’s.
  • The load is paid per request. That is the trade, and it is why entry still exists: warm a hot function with its handler, and let the long tail arrive as scripts.

Implementing script is optional. An agent that ignores the field serves the handler it was warmed with, and zygo agent test reports it as “functions only”.

A workspace per request (1.5)

EXEC may carry a workspace: the folder with the files this request’s caller sent, and where the handler leaves what it wants back. An agent puts it in ZYGO_WORKSPACE and makes it the child’s working folder before any handler code. It is not a fixed path, and cannot be. A forked child has no right to create the mount namespace that would make one path mean a different folder to each request: measured on Linux 6.12, unshare(CLONE_NEWNS) fails with EPERM for the child, whatever the seccomp profile. So three weaker things keep requests apart: the parent folder is mode 0311 and cannot be listed, the name is 128 random bits, and the folder is removed when the request ends. An agent must not look around the parent, which holds other requests’ folders, including other tenants’.

Every request also needs a temporary folder of its own, workspace or not. /tmp is one tmpfs for the whole sandbox, so without one a file request 1 leaves in /tmp is there for request 2 — for another tenant, in a pool. Both reference agents make /work/tmp-<128 random bits> (mode 0700) for each child, point TMPDIR, TMP and TEMP at it, and remove it when the child is gone. The protocol asks the same of any agent.

Versions

proto goes up only for a breaking change. Adding an optional field or message is not breaking, and an agent must ignore fields it does not know. So every addition since the first version still announces proto: 1. A supervisor that sees an unknown proto refuses the agent rather than guessing.

VersionAddsAn agent that does not know it
1.1EXEC.scriptignores it and serves its own handler
1.2CANCEL, DONE.cancelledanswers bad_message; the kill still lands
1.3EXEC.stream, CHUNKanswers the same DONE as before
1.4PING with idis bounded by the request’s deadline
1.5EXEC.workspaceruns the handler where it was; the request fails to find its files

The contract in short

  • One frame is a 4-byte big-endian length and that many bytes of UTF-8 JSON.
  • You get a connected socket at file descriptor 3.
  • Send READY when your warm-up is done. Answer PING with PONG.
  • On EXEC: create a process, send FORKED with its pid, and let it do nothing until GO arrives.
  • Answer every EXEC with exactly one DONE or one ERROR with the same id.
  • Concurrency is optional. An agent that serves one request at a time answers the second with ERROR / overloaded, which is conforming.
  • Never run a request in the agent’s own process. Its memory stays as it was just after a clean import, so request n cannot see what request n−1 did.
  • Return stdout and stderr in separate fields, beside the exit code and the measurements.
  • A frame that is whole but is not a valid message gets ERROR / bad_message, and the agent carries on. (A length above 32 MiB is different: the stream cannot be recovered, so close the connection.)

The strict child filter

Seccomp is the kernel feature that limits which system calls a process may make (chapter 4). Under seccomp = "strict" the supervisor sets ZYGO_CHILD_SECCOMP: base64 of a raw seccomp-bpf program, in the host’s byte order, that the child installs with prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) after PR_SET_NO_NEW_PRIVS. The child does this after GO and before any handler code. It removes execve and process creation from the child without taking them from the agent. A value the agent cannot decode is a start-up ERROR.

There are exactly two conforming answers: install the filter, or fail the request. Running the request with only the sandbox’s filter is not allowed, because the function’s author asked for a tighter wall and would not get it. A language that cannot reach prctl can still conform. The Node agent ships a forty-line C shared object whose constructor installs the program; without it, it falls back to Node’s own permission model (no child processes, no native addons, no WASI) and says which in READY. The sh agent refuses every request under strict instead. zygo agent test checks this, and the sh and Node example agents both failed it silently until the check existed.

Checking an agent: zygo agent test

zygo agent test runs a conformance suite against your agent over a real socket. It starts the agent with the control socket at descriptor 3 — where a sandboxed agent finds it too — and passes everything after -- to the agent as its arguments. It runs on the host, not in a sandbox: what is under test is the conversation, and a sandbox would add failures that are Zygo’s rather than yours. It exits with 1 if any check failed, and 0 otherwise. With --json it prints the report as JSON.

zygo agent test BINARY [--script FILE] [--script-spawn FILE] [--pool-script FILE] -- [ARGS…]

# the reference Python agent
zygo agent test python3 -- agents/python/zygo_agent.py --fd 3 \
    examples/agents/conformance/handler.py

# the reference Node agent
zygo agent test node -- agents/node/zygo_agent.js \
    examples/agents/conformance/handler.js

# the sh one
zygo agent test /bin/sh -- examples/agents/sh/agent.sh examples/agents/sh/handler.sh
OptionWhat it adds
--script FILEThe protocol 1.1 check: a script, in the agent’s own language, sent inside EXEC.
--script-spawn FILEA script whose top-level code starts a program. Checks that the child filter is on before the script’s first line.
--pool-script FILEThe agent holds no handler: send this file as every request’s script (the runtime-pool shape).
-- ARGS…Arguments for the agent itself.

The test handler

The handler you start the agent with must follow a small contract, or there is nothing to check about the answers. examples/agents/conformance/ has one for each language.

  • Return the event it was given, unchanged.
  • If event.stdout is a string, write it to stdout.
  • If event.stderr is a string, write it to stderr.
  • If event.spawn is a string, start a program that prints it. That is what the strict child filter takes away, so the suite must be able to try.
  • If event.sleep_ms is a number, sleep that long, so that the cancel check has a request to arrive during.

What it checks

The suite stops early if READY never comes, because nothing else means anything without a warmed agent. It also stops at the first missed deadline, and marks the remaining checks as skipped rather than failed many times over.

#CheckRequired?
1The agent announces itself with READY (proto 1, a pid, a runtime name).yes
2PING is answered by PONG with the same seq.yes
3EXEC is answered by FORKED, naming a process that is not the agent.yes
4The child does nothing until GO.yes
5The event reaches the handler and its result comes back.yes
6stdout and stderr come back in separate fields.yes
7Two requests in flight are both answered, each with its own id.yes
8A frame that is not a message gets ERROR, not a crash.yes
9A script in EXEC is loaded by the child (1.1).needs --script
10ZYGO_CHILD_SECCOMP is installed in the child, or the request is refused. A second copy of the agent is started with the variable set, and the handler is asked to spawn.yes, on Linux (skipped elsewhere, or if the handler cannot start a program even without a filter)
11The child filter is installed before the script’s first line (1.1).needs --script-spawn
12A cancelled request comes back as DONE{cancelled} (1.2).optional
13Output arrives in CHUNKs before the DONE (1.3).optional
14A long request is reported alive with PING{id} (1.4).optional
15SHUTDOWN makes the agent exit.yes

The first time this suite ran, it found a real bug in the reference Python agent: a frame that was not valid JSON raised out of the read loop and killed the agent, taking every request in flight with it. It is an ERROR now.

How the optional checks are judged

An optional feature that is missing is skipped, not failed: an agent that only serves functions is conforming. The last line says “conforms to protocol 1” when nothing failed. A feature that is only half there is reported as partial — “conforms, with gaps named above” — and does not fail the run.

  • Scripts (--script). Pass a file in the agent’s own language: the suite cannot guess it, and Python sent to a Node agent fails in a way that looks like “does not implement 1.1”. The suite sends the script three ways: as source, as a path, and with a digest that does not match. An agent that runs the third has no defence against a tenant swapping the file, and fails. One that handles source but not path is partial, because path is what the supervisor really sends.
  • Filter before script (--script-spawn). The script is run once without the filter, to prove it can start a program, and once under it, to prove it cannot.
  • Cancel needs no flag. The suite plays the supervisor’s whole part: it sends CANCEL, kills the child, and checks the answer is DONE with cancelled. An agent that answers ERROR / bad_message is reported as not implementing 1.2; the sh agent is the worked example.
  • Stream needs no flag, and it checks order, not content. The handler prints, then sleeps for a second and a half, and the first chunk must arrive while it is still asleep. Otherwise an agent that buffered everything would pass.
  • Heartbeat starts a long request and waits for a PING with its id. An agent that sends none is skipped.

Checking a runtime-pool agent

An agent may also start with no handler at all: one warm interpreter per image and dependency set, with the code arriving in each EXEC. That is a different path through the agent — the child loads tenant code after the fork and under the child filter — so passing in one shape says little about the other. Check both. --pool-script sends the same echo handler as every request’s script, with a digest, so every check above runs unchanged.

zygo agent test python3 \
    --pool-script examples/agents/conformance/handler.py \
    --script examples/agents/conformance/script.py -- \
    agents/python/zygo_agent.py --fd 3          # note: no handler

zygo agent test node \
    --pool-script examples/agents/conformance/handler.js \
    --script examples/agents/conformance/script.js -- \
    agents/node/zygo_agent.js

make conformance-node sends conformance/script.ts in the pool run, because that is where TypeScript is checked end to end. The file goes up as written, the digest is over those bytes, and it contains an enum so that a runtime which only blanks out type annotations cannot pass.

Four rules an agent has to keep

Each rule is in the protocol document with its reason. They are here because each has been got wrong at least once, in this repository.

  1. The child never returns to the parent’s loop. A forked child that unwinds back into the agent’s serve() runs the parent’s interpreter teardown, and then there are two agents on one socket.
  2. Nothing runs before GO. The supervisor has not put the child in a cgroup yet, so anything that runs early has no limits.
  3. A malformed frame is reported, not fatal. The stream is still aligned; killing the agent takes every request in flight with it.
  4. Every EXEC gets exactly one answer. A request that is refused, overloaded or unreadable still gets a reply, because the supervisor’s only other choice is to wait out the deadline.

Where the milliseconds go

These are recommendations, not requirements, because none can be checked from outside. But they are where an agent’s speed is won or lost.

  • Import nothing lazily on the request path. Every module the child uses must already be loaded in the agent, so the child gets it copy-on-write. In the reference agent, a deferred import inspect (~10 ms) and import random (~2 ms) put the median at 11.8 ms against a 2 ms budget. Moving them into the agent halved it, and the median on Linux is now 1.9 ms.
  • Freeze the heap before forking. In a runtime that counts references, the first garbage-collection pass in a child touches every shared object and copies the page it lives on. CPython’s gc.freeze() cut per-request copying from 14.96 MB to 0.81 MB.
  • Reseed the random-number generator in the child, or every request makes the same “random” tokens, temporary names and jitter.
  • Exit hard with _exit(), so that exit handlers and teardown cannot damage state the parent also owns.
  • Bound captured output — Zygo’s default is 256 KiB per stream — and say when you cut it.

TypeScript, without a build step

A .ts handler and a .ts script both run as they are. There is no compile step, no bundler, and nothing cached between requests: the types are removed as the module loads, in the child, after the fork — the same place and moment a .js script is compiled.

[fn.resize]
image = "node:22-slim"
entry = "./resize.ts"     # `runtime` is inferred from the extension

Or, over the API, by uploading the TypeScript itself:

script = client.put_script(open("resize.ts").read())
client.run_script("node-pool", script.sha256, {"url": "..."})

Three things to know about TypeScript

  • The digest is over the source as uploaded, not over the JavaScript the agent makes from it, which would differ between Node versions and break every request. The check a child makes before loading a script is what makes /run/script/<digest> safe on a shared user id, and it is unchanged.
  • enum, namespaces and parameter properties work. They are code, not just annotations, so they need Node’s transform mode rather than the default strip mode. The agent tries strip first and falls back, because strip keeps every line and column where the tenant wrote it and transform does not.
  • There is no file extension on the wire. A script arrives as /run/script/<digest> or as bytes, so the agent decides by what the source is. Valid JavaScript is valid TypeScript, so it compiles the file as JavaScript first. Only a SyntaxError — raised before any line of the module body runs — sends it back to strip types and try again. A file with no types comes back from the stripper unchanged, so a plain JavaScript syntax error stays a JavaScript error.

This needs Node 22.13 or later, where module.stripTypeScriptTypes arrives. On older Node the agent uses amaro if the image’s dependency set has it, and otherwise says so rather than running the file as JavaScript. Amaro is not bundled: it is a megabyte of WebAssembly, and this agent is loaded into every Node sandbox Zygo runs.

A handler that computes without yielding

A request that spends three seconds in a tight loop is the test of whether an agent’s own housekeeping survives. tests/linux/agent_stall.py measures it. It drives an agent over a socket pair and sends one streaming request whose script writes, reports progress, then spins without yielding. It prints when each frame came back. Against both reference agents, with the handler spinning for three seconds:

PythonNode
PONG to a PING sent one second into the spin0.5 ms0.3 ms
the request’s heartbeat (1.4)on timeon time
output written before the spinpassed on at oncepassed on at once

Neither agent goes quiet, and the reason is in the design. The request runs in a process of its own — a fork in Python, a pooled worker in Node — so the loop that answers PING and CANCEL holds no tenant code and has nothing to block it.

Lost output, and why there is no thread pool

What the measurement did find was lost output. A Node handler that wrote a megabyte and then spun had 70 KB of it passed on, and 978 KB silently lost: Node queues a write to a pipe in memory, and process.exit throws away what is still queued. The worker now makes stdout and stderr blocking, as Python’s are. Every byte is passed on as it is written, and the DONE carries the last 256 KiB with a note that it was cut. If you write an agent in a runtime with asynchronous output, this is the part to get right.

A thread pool inside the worker, to run tenant code off the worker’s own loop, would buy nothing more, so there is none. Measured on node:22, one worker_threads isolate costs 13 MB of memory and 15–23 ms to start, against 43 MB and about 2 ms for the whole Node agent now. A tenant whose handler must report progress while it computes can start one itself: the strict filter refuses clone only without CLONE_THREAD, and the permission fallback passes --allow-worker for exactly that reason. What would reopen the question is a measurement showing an agent going quiet without a thread pool; the one above shows the opposite.

The runtimes that have no agent

Deno and Bun do not have one, and are not waiting for one: ADR 0003 records why, and what would change it. Both start in a few milliseconds, so a warm-exec pool serves them with no protocol at all — cmd = ["deno", "run", "--allow-none"], with each request’s script as the last argument. That gives up streaming, progress(), workspaces and per-request tenant limits; see examples/warm-exec/. The same is true of anything that starts fast: Go, Rust, C, bash. The runtime name go is accepted in sandbox.toml, but no Go agent ships, so a Go function uses warm-exec. Write an agent when there is something slow to keep warm, not because a runtime is popular.

What there is to read

  • examples/agents/ — the contract in short form, what zygo agent test checks, and the things that cost an agent its milliseconds. Read it before writing one.
  • examples/agents/sh/ — a complete agent in POSIX sh and jq, about 130 lines, passing the same checks the Python one does. It is the shortest proof that the protocol is language independent. It cannot reach prctl, so under strict it refuses every request.
  • agents/python/ — the reference Python agent, and its conformance suite.
  • agents/node/ — the reference Node agent. Node has no fork(), so it keeps a pool of pre-loaded workers, each serving one request and exiting, with a replacement started off the request path. Everything else is identical, which is the point of a protocol rather than a library. It also carries the forty-line C helper that lets a Node worker install the strict child filter.
  • examples/warm-exec/go/ — a Go program as a warm function, where the whole integration is a cmd.
  • spec/protocol.md — the full protocol, with every field. Its test fixtures live in spec/fixtures/ and are used by both the Rust tests and agents/python/test_zygo_agent.py.

19. Every command

This is the complete list of zygo commands, every flag, and every default. It is written from the parser (crates/zygo-cli/src/cli.rs) and from the code behind each command. zygo <command> --help prints the same flags; this chapter adds what the help cannot: what each command really does, what it needs, and how it ends.

The commands on one page

  ONE-SHOT                 WARM FUNCTIONS                 A PROJECT (sandbox.toml)
  zygo run                 zygo serve    zygo exec        zygo up      zygo down
                           zygo ps       zygo stop        zygo spec validate | explain
                           zygo logs     zygo shell
                           zygo top      zygo stats

  IMAGES                   ACCESS AND SECRETS             PROGRAMS AND AGENTS
  zygo pull                zygo login                     zygo api
  zygo images              zygo token  mint | ls | revoke zygo mcp
  zygo image rm | prune    zygo secrets keygen | set |    zygo agent test
                                        ls | rm

  THE HOST                 MEASURING                      SHELL HELPERS
  zygo doctor [--fix]      zygo bench warm | cold |       zygo completion
  zygo backend list |                 load | all
               install     (hidden) zygo supervisor run | status | stop

How to read this chapter

Each command has a line showing how to call it, a few sentences on what it does, a table of its flags, and notes on output and exit codes. [x] means optional, … means it can repeat, A | B means one of them. Sizes are written like 256M, durations like 30s; chapter 20 has the exact rules, which are the same for flags and for the file. Exit codes are collected in chapter 21.

Global flags

These work with every command, before or after the command name.

FlagDefaultMeaning
--jsonoffPrint machine-readable JSON instead of tables and lines. Logs become JSON too.
-v, --verbosewarnings onlyMore log output; repeat for more (-v info, -vv debug, -vvv trace). ZYGO_LOG overrides it.
--data-root DIR$XDG_DATA_HOME/zygoUse another data folder: images, caches, tokens, secrets. Also read from ZYGO_DATA_HOME.
-h, --help · -V, --versionHelp for any command · the version.

Flags shared by run, serve and mcp

Two groups of flags describe a sandbox, and they mean the same thing wherever they appear. Each maps to a sandbox.toml field and wins over it.

Limit flagDefaultMeaning
--mem SIZE256MMemory limit (at least 8M).
--cpu CORES1.0CPU quota in cores, e.g. 0.5.
--pids N64Most processes and threads at once; the fork-bomb limit.
--timeout DURATION30sWall-clock limit. 0 needs --allow-unlimited.
--scratch SIZEsmaller of 64M and half of --memSize of the writable /tmp.
--nofile N1024Most open files.
Sandbox flagDefaultMeaning
--isolation ns | gvisor | vmnsWhere the wall is.
--seccomp default | strict | permissivedefaultThe syscall profile (chapter 24).
--net none | egress | full | hostnoneNetwork mode; bridge is accepted for full.
--allow RULE …noneEgress allowlist entry: host:port, *.domain:port, CIDR:port.
--mount HOST:GUEST[:ro|rw] …noneBind mount, read-only unless :rw.
--env KEY=VALUE …noneEnvironment variable.
--user UID1000uid inside the sandbox.
--workdir PATH/appWorking folder inside the sandbox.
--allow-host-netoffPermit --net host, which removes the network boundary.
--allow-private-netoffPermit private and link-local ranges.
--allow-unlimitedoffPermit --timeout 0.

-f, --file PATH names the spec file on every command that reads one; without it, sandbox.toml is searched for upwards from the current folder.


zygo run — a one-shot sandbox

zygo run [FLAGS] IMAGE [COMMAND [ARGS…]]

Builds a fresh sandbox from IMAGE, runs COMMAND in it (or the image’s own entrypoint), and removes everything when it exits. Standard input, output and the exit code pass straight through, so it behaves like the program itself. Everything after IMAGE belongs to the command: zygo run alpine sh -c "echo --mem" does not set a memory limit. Chapter 12 teaches it step by step.

FlagDefaultMeaning
limit and sandbox flagssee above
-f, --file PATHsearchedUse this spec’s [defaults] under the flags; [fn.*] tables are ignored.
-t, --ttyoffGive the sandbox a terminal of its own. Without it, the sandbox shares yours — colours work, but the program can write to your terminal.
--requirements FILEnoneA requirements.txt built once into a shared venv at /venv, /venv/bin first on PATH.
--pull missing | never | alwaysmissingWhen to download the image. never refuses a missing image before anything starts (exit 1, outcome phase: plan).
--outcome FILEnoneWrite why the sandbox ended, as JSON (chapter 21).
--dry-runoffPrint the plan — command, root, mounts, network, seccomp, Landlock, cgroup values — and run nothing. With --json, the full plan including the allowed syscalls.
-q, --quietoffHide Zygo’s own progress lines on stderr. The program’s output is never hidden.

Exit: the program’s own code; 137 if the deadline or the memory limit killed it; 2 for a spec error; 125 if this host cannot run sandboxes; 1 for other Zygo errors. Signals: the first Ctrl-C is passed to the program; a second one kills it.

zygo serve — warm a function or a pool

zygo serve HANDLER --name NAME [FLAGS]
zygo serve --runtime NAME [--image IMAGE] [--agent AGENT] [FLAGS]

The first form builds a sandbox, loads HANDLER in it, and keeps it warm under NAME. The second form warms a runtime pool: an interpreter with no code, for scripts that arrive with each request. It starts the supervisor if none is running. Chapter 13 explains both.

FlagDefaultMeaning
HANDLER—The handler file (.py, .js, .ts, …). Not with --runtime.
--name NAME—The function’s name. Needs a HANDLER.
--runtime NAME—Serve a pool under this name instead.
--image IMAGEthe spec’s, or python:3.12-slim / node:22-slim for the runtimeThe image to warm from.
--agent python | node | PATH—With --runtime: which agent the pool runs.
--min-warm N · --max-warm N1 · larger of min-warm and 4With --runtime: zygotes kept warm, and the most it may grow to.
--requirements FILEnoneShared, cached venv, as for run.
--concurrency N4Requests at once per zygote.
--idle-timeout DURATION10mPause the zygote after this long idle.
--mode function | stdinfunctionHow the handler is called.
--secret NAME …noneDeliver $NAME from this shell as /run/secrets/NAME, per request.
-f, --file PATHsearchedSpec to read.
limit and sandbox flagssee above

Output: the function’s runtime, resident memory, import time and warm-up time. Exit: 0 when warm; 2 for a spec error; 125 if it cannot start.

zygo exec — call a warm function

zygo exec NAME [EVENT]
zygo exec --runtime NAME --script FILE|sha256:… [--entry-point FN] [EVENT]

Sends EVENT (JSON; read from standard input when left out; empty means null) to a warm function and waits. The result goes to stdout; what the handler printed goes to stderr. With --runtime, runs a script in a pool instead; the positional argument is then the event.

FlagDefaultMeaning
--runtime NAME—Run a script in this pool. Needs --script.
--script FILE | sha256:…—A file on this host, or the digest of a script already stored (PUT /scripts).
--entry-point FNhandlerThe function in the script to call.
--batchoffRead one JSON event per line from stdin, run them in parallel, print one answer per line in the same order.
--timeout DURATIONthe function’s timeoutGive up after this long.

Exit: the request’s own code; 137 if its deadline killed it; 75 if the function is busy; 4 if there is no such function; 125 if no supervisor is running. With --batch: 0 only if every line succeeded.

zygo ps — list warm sandboxes

zygo ps

Columns: NAME, STATE (starting, warm, paused, cold, failed), RUNTIME, RSS (resident memory), REQUESTS, FAILURES. It never starts a supervisor: with none running it says so and exits 0.

zygo logs — a function’s recent log

zygo logs NAME [-f] [-n N] [--failed]

The zygote’s own output, and one entry per request with its exit, duration, stdout and stderr. The supervisor keeps the last 500 entries per function, across replacements and cold spells.

FlagDefaultMeaning
-f, --followoffKeep printing new entries (checks every 500 ms).
-n, --tail N50How many recent entries to start with.
--failedoffOnly requests that failed.

With --json, one entry per line.

zygo stop — stop sandboxes

zygo stop NAME
zygo stop --all

Stops one function or runtime pool by name, or everything — every function and every pool — and then the supervisor itself. Give a name or --all, not both. --all with nothing running exits 0; a named stop with no supervisor is an error (125), and a name that is neither a function nor a pool is “no function or runtime pool named …” (exit 4). On a Mac, stop --all also stops the Linux VM afterwards.

Output: one line per thing stopped — stopped NAME for a function, stopped runtime.NAME for a pool, and stopped the supervisor when --all ended it — so you can see which kind went. nothing to stop means just that. A name that is both a function and a pool stops both: stop means “forget this name”. To stop only one of the pair, use the API, which keeps them apart: DELETE /fn/{name} never reaches a pool, and DELETE /runtimes/{name} never reaches a function.

zygo top — live resource table

zygo top [-i SECONDS] [--once]

ps on a timer, plus rates. Functions: NAME, STATE, RSS, REQ/S, REQUESTS, FAILURES. Pools: RUNTIME, WARM, PAUSED, ROOM (how many more zygotes it may start), IN/QUEUE, RSS, REQUESTS, FAILURES. Rates need two samples, so the first frame shows —.

FlagDefaultMeaning
-i, --interval SECONDS2Time between frames.
--onceoffOne frame, then exit — for scripts. --json implies it.

zygo stats — latency and outcome summary

zygo stats [NAME]

For each function and pool: STATE, REQUESTS, FAILURES, SAMPLES, p50 (a usual request), p99 (the slowest 1 in 100), MAX, KILLED. Counters run from when the function was warmed; latencies come from the log ring. p99 is shown only with 100 samples or more. KILLED counts timeouts and other 137 exits (usually out of memory).

zygo up — start a whole project

zygo up [-f PATH] [--relock]

Serves every [fn.*] in the spec, in the order they are written. A function whose spec, secret values, handler and requirements are all unchanged is left warm; a changed one is replaced blue/green: the new one warms, then takes new requests, while running ones finish on the old. One failure does not stop the others. It writes zygo.lock (chapter 20). [runtime.*] pools are not started by up, and the --allow-* flags do not exist here, on purpose.

FlagDefaultMeaning
-f, --file PATHsearchedSpec to read.
--relockoffAccept an image that moved under an unchanged tag, and rewrite zygo.lock.

Output: one line per function — ✓ started or replaced, · unchanged, ✗ failed with the reason. Exit: 1 if any function failed.

  zygo up, for one changed function
  old ──────── serving ──────────────── finishing running requests ──▶ stopped
  new            └─ warming ─▶ ready ─▶ takes every new request ──────────────▶

zygo down — stop a project

zygo down [-f PATH]

Stops the functions the spec declares, and nothing else. With no supervisor running, it says so and exits 0.

zygo spec — check and explain the spec

zygo spec [-f PATH] validate
zygo spec [-f PATH] explain [NAME]

validate resolves every function and every pool, so limits, names and network rules are really checked, and prints the warnings once each. explain NAME prints the final settings for one function or one pool after every layer is merged — and says, for each value, which layer it came from. A pool is resolved the way serve --runtime resolves it (strict unless a layer says otherwise) and shows its min_warm and max_warm too; a name that is both a function and a pool is explained as the function. explain with no name shows what zygo run would use. Both need a sandbox.toml.

zygo pull — download an image

zygo pull IMAGE [--platform OS/ARCH[/VARIANT]]

Downloads an image into the local store, with progress lines. On Linux it then builds the Python bytecode layer once, if the image needs one (chapter 15).

FlagDefaultMeaning
--platformthis host’sPull for another platform, e.g. linux/amd64.

zygo images — list local images

zygo images

Columns: reference, digest (first 12 characters), layers, size, and when it was pulled.

zygo image rm — remove images

zygo image rm IMAGE…        (alias: zygo image remove)

Removes an image, the derived +system images built on it, and any layers only they used. Refused while a warm function runs on it: stop the function first.

zygo image prune — free disk space

zygo image prune [--dry-run] [--unused-for DURATION] [--blobs]

With no flags, deletes only what nothing can reach any more: layers of removed images, and venvs, flattened roots and derived-layer records whose image is gone, plus leftover temporary roots. It prints what a flag would also collect.

FlagDefaultMeaning
--dry-runoffSay what would go, and how much it would free; delete nothing.
--unused-for DURATIONoffAlso delete venvs and flattened roots not used for this long, e.g. 30d.
--blobsoffAlso delete the compressed copy of every unpacked layer. Roughly halves the store; a lost layer then means a new download.

zygo login — a private registry

zygo login REGISTRY [-u USER] [--password-stdin]

Asks for the password with echo off, checks it against the registry, and only then stores it in Zygo’s own auth.json. There is no --password flag: an argument is visible to every user in ps and lands in shell history. Zygo reads ~/.docker/config.json too, but never writes to it; where both have a credential, Zygo’s wins. docker.io, index.docker.io and registry-1.docker.io are the same registry.

FlagDefaultMeaning
-u, --username USERaskedThe account name.
--password-stdinoffRead the password from standard input, for CI.

zygo secrets — the per-tenant secret store

zygo secrets keygen
zygo secrets set TENANT NAME [--stdin]
zygo secrets ls TENANT          (alias: list)
zygo secrets rm TENANT NAME

An encrypted store of secrets per tenant, used by functions served through the API. keygen prints a new 32-byte key; put it in ZYGO_SECRETS_KEY (or a file named by ZYGO_SECRETS_KEY_FILE) before the supervisor starts. A passphrase is refused, and a lost key means every stored secret is lost. set reads the value with echo off, or from stdin with --stdin (at most 64 KiB); there is no --value flag. ls prints names, never values; there is no way to read a value back. Chapter 14 explains how the store and --secret fit together.

zygo token — API tokens

zygo token mint [--tenant TENANT]
zygo token ls                   (alias: list)
zygo token revoke ID

mint creates a token and prints its secret — zygo_ and 64 hex characters — on stdout, once; only a hash is stored. Without --tenant it is an operator token (the host’s); with it, a tenant token (one customer’s, and the tenant is created if new). Because the secret goes alone to stdout, ZYGO_API_TOKEN=$(zygo token mint) works. ls lists id, scope and state; revoke works from the next request, and keeps the record so old logs still make sense.

zygo api — the HTTP API

zygo api [-f PATH] [--listen ADDR] [--no-auth] [--allow-deploy]
         [--allow-private-net] [--openapi]
         [--otlp-endpoint URL] [--otlp-interval D] [--usage-webhook URL]
         [--usage-interval D]

Runs the HTTP API in the foreground, starting the supervisor if needed. By default every caller needs a bearer token — ZYGO_API_TOKEN, or one from zygo token mint. Chapter 17 has every route.

FlagDefaultMeaning
--listen ADDR[api] listen, else 127.0.0.1:7700IP:PORT, or unix:///path.
--no-authoffNo tokens. Refused except on a unix socket or a loopback address.
--allow-deployoffLet the bootstrap token create and destroy sandboxes, not only call them. That makes it a shell as your user; think twice.
--allow-private-netoffLet what deploy callers serve name private and link-local addresses in allow. A request body can never set this. Does nothing without --allow-deploy.
--openapioffPrint the OpenAPI 3.1 document and exit.
--otlp-endpoint URL$OTEL_EXPORTER_OTLP_ENDPOINTPush metrics to an OpenTelemetry collector (/v1/metrics is added).
--otlp-interval DURATION60sHow often to push.
--usage-webhook URLnonePOST batches of usage events for billing, at least once.
--usage-interval DURATION10sHow often to deliver them.

zygo mcp — tools for an AI agent host

zygo mcp [-f PATH] [--workspace DIR] [--python-image I] [--node-image I]
         [--sh-image I] [limit and sandbox flags]

Speaks the Model Context Protocol on stdin and stdout, which is how an agent host starts a tool server. The flags are a ceiling the model cannot raise: no tool accepts an image, mount, network or limit.

FlagDefaultMeaning
--workspace DIRa temporary folder, removed on exitMounted read-write at /work for every run_code.
--python-imagepython:3.12-slimImage for language: "python".
--node-imagenode:22-slimImage for language: "node".
--sh-imagealpine:3Image for language: "sh".
limit and sandbox flagssee aboveApplied to every run_code.

zygo shell — a debug shell inside a warm sandbox

zygo shell NAME [-- COMMAND…]

Starts a fresh process inside the function’s namespaces: it sees the sandbox’s files, processes, network and host name. The warm zygote is not touched and keeps serving. The shell holds no capabilities, but it is on purpose not under seccomp, Landlock or the function’s cgroup, so the memory limit cannot kill your debugging session. It runs bash, sh or busybox sh, or COMMAND if given.

zygo doctor — can this host run sandboxes?

zygo doctor [--fix [--yes]]

Tries each requirement for real rather than reading a setting: kernel version, user namespaces, /proc, cgroup v2 delegation, whether moving a process into a cgroup can stall (cgroup moves), whether the systemd unit holding the sandboxes would be stopped by one of them being OOM-killed (systemd OOM policy), overlayfs, Landlock, seccomp, subordinate uids, KVM, the guest kernel, runsc, and the network helpers. Each line says ok, degraded, - (absent) or FAIL, with the fix under it. On a Mac it checks the host side and then the VM’s own doctor. Chapter 11 walks through it.

FlagDefaultMeaning
--fixoffApply the fixes that are one command each — the AppArmor user-namespace rule, cgroup delegation, the passt and nftables packages, the AppArmor profile on pasta, and cgroup2’s favordynmods option (on the host, never in a container) — after printing every command and what it costs, and asking.
--yesoffWith --fix: do not ask.

Exit: 0 if no check failed.

zygo backend — optional isolation backends

zygo backend list
zygo backend install gvisor | vm

list shows ns, gvisor and vm and whether this host can use each. install gvisor downloads gVisor’s runsc from Google’s release bucket, checks its SHA-512 before unpacking, and installs it under the data folder. install vm downloads nothing: it needs a build with the vm feature and a guest kernel in place, and says how to get one.

zygo agent test — check an agent against the protocol

zygo agent test BINARY [--script F] [--script-spawn F] [--pool-script F] [-- ARGS…]

Runs your agent and has the conversation the supervisor would have: READY, PING, a fork per request, the wait for GO, output kept apart, two requests at once, a bad message, cancel, streaming, heartbeats, shutdown. Chapter 18 describes each check. Exit 1 if any failed.

FlagMeaning
--script FILEA script in the agent’s language, to check that a script sent with a request is loaded in the child.
--script-spawn FILEA script whose top level starts a program, to check the child’s filter is on before the script’s first line.
--pool-script FILEThe agent holds no handler: send this file with every request (the runtime-pool shape).
-- ARGS…Arguments for the agent.

zygo bench — measure this host

zygo bench warm  [--n 10000] [--rate R] [--cpu C] [--no-cgroup] [--pool [--scripts 1000]] [-- CMD…]
zygo bench cold  [--n 50] [--image python:3.12-slim] [--command "…"]
zygo bench load  [--seconds 10] [--concurrency 4] [--cpu C]
zygo bench all   [--quick]

warm measures the warm path (or warm-exec with -- CMD, or a pool with --pool); cold measures zygo run with a pulled image; load measures throughput with several clients; all runs every published measurement and compares it with the numbers in chapter 25. Each prints PASS or FAIL against its budget. all exits 2 — “no verdict” — when the machine was throttled or busy while it ran, because such a number is not about Zygo.

zygo completion — shell completion

zygo completion bash | zsh | fish | elvish | powershell

Prints a completion script, generated from the parser itself so it can never drift from the real flags.

zygo completion zsh > "${fpath[1]}/_zygo"

zygo supervisor — the warm pool’s own process (hidden)

zygo supervisor run | status | stop

Not shown in --help, because serve, up, api, token and secrets start a supervisor when they need one. run keeps it in the foreground, which is how you see why it will not start. status prints its version, pid and socket (exit 1 if none). stop stops the supervisor only; it goes through the socket, or through the pid file and SIGTERM when a version mismatch means the socket will not listen.

Which commands start or need a supervisor

Starts one if neededNeeds one running (else exit 125)Fine without one
serve, up, api, token …, secrets set/ls/rmexec, logs, top, stats, shell, stop NAMErun, ps, down, stop --all, pull, images, image …, login, doctor, spec …, backend …, completion, secrets keygen

On a Mac

The zygo on a Mac is a small forwarder. completion, doctor, agent test and api --openapi run on the Mac itself; everything else runs inside the Linux VM Zygo manages, with the same arguments, folder and streams. Every path you pass — mounts, -f, --requirements, --outcome, handler files — must be under your home folder, because only that is shared with the VM. Chapter 11 explains the VM.

20. sandbox.toml, field by field

One file describes a project: its functions, its runtime pools and its API. This chapter lists every section and every field the file accepts, with the default Zygo uses when you leave it out. It is written from the parser and the resolver (crates/zygo-core/src/spec/), not from memory.

The smallest project

Two files make a whole project: the spec, and one handler.

  my-project/
  ├── sandbox.toml      what to run, and how
  └── to_lower.py       the code
# sandbox.toml
[fn.lower]
entry = "./to_lower.py"       # the image is python:3.12-slim, guessed from .py
# to_lower.py
def handler(event):           # event: the JSON the caller sent, as a dict
    return {"text": event["text"].lower()}   # returned as JSON
zygo up                                   # warm it once
zygo exec lower '{"text": "Hello WORLD"}' # prints {"text": "hello world"}

Everything else in this chapter is optional: a field you leave out has a safe default. Chapter 13 explains handlers in full.

The shape of the file

[defaults]            # applies under every [fn.*] and every [runtime.*]
mem = "256M"

[fn.resize]           # a function: one handler, warmed into one zygote
entry = "./resize.py"

[runtime.py312]       # a runtime pool: an interpreter with no code in it
agent = "python"

[api]                 # where `zygo api` listens, and how it checks callers
listen = "127.0.0.1:7700"

There are exactly four sections. An unknown section or an unknown field is an error, not a warning, so a typo cannot silently do nothing. There is no section for tenants: they are created through the API and stored by Zygo (chapter 17).

How the layers merge

  highest ┌───────────────────────────────────────┐
          │ a CLI flag, or the body of an API call│   --mem 512M
          ├───────────────────────────────────────┤
          │ [fn.<name>]  or  [runtime.<name>]     │   mem = "384M"
          ├───────────────────────────────────────┤
          │ [defaults]                            │   mem = "256M"
          ├───────────────────────────────────────┤
  lowest  │ Zygo's built-in defaults              │   mem = 256M
          └───────────────────────────────────────┘
          the highest layer that sets a field wins

A list or table — allow, mounts, env, secrets, cmd, system — replaces the one below it; it is never added to it. So allow in [fn.fetch] is the whole allowlist for that function, not an addition to one in [defaults]. zygo spec explain <name> prints the result of the merge, and zygo spec validate checks the file without running anything.

Where the file is found, and relative paths

-f PATH (or --file PATH) names the file. Without it, Zygo looks for sandbox.toml in the current folder, then in each parent folder in turn, and uses the first one it finds. Relative paths in the file — entry, requirements, and the host side of mounts — are relative to the folder the file is in, not to where you ran the command. Without a spec file, they are relative to the current folder.

Names

Function and pool names ([fn.<name>], [runtime.<name>]) are 1 to 64 characters: a letter or digit first, then letters, digits, ., _ or -. Names in env and secrets follow the shell’s rule: a letter or _ first, then letters, digits or _. A system package is a Debian package name, at least two characters of a-z 0-9 + . -, optionally followed by =version.

Value syntax

KindWritten asNotes
bytes"256M", "1.5G", "512K", or a bare integerA bare integer is bytes. Suffixes B, K/KB/KiB, M/MB/MiB, G/GB/GiB, T/TB/TiB, any case. All are binary: 1M is 1 MiB.
duration"30s", "250ms", "5m", "1h", "2d", or a bare integerA bare integer is seconds. Units are case-sensitive: ms, s, m, h, d.
cpu0.5, 2, or "1.5"Cores. Must be above zero.
enums"ns", "strict", "function"isolation, seccomp, mode and [api] auth must be lowercase in the file. network and the built-in runtime names accept any case.

What to run

FieldTypeDefaultWhat it does
imageimage referencepython → python:3.12-slim, node → node:22-slim; otherwise requiredThe OCI image that becomes the root file system: python:3.12-slim, ghcr.io/org/app:1.2, or name@sha256:….
entrypath—The handler file. Makes this an agent function: loaded once, forked per request. Cannot be used with cmd. Refused in a pool.
cmdlist of strings—The program to run. Without entry or a runtime, makes this a warm-exec function: a fresh process per request, event on stdin, result on stdout. For zygo run, the image’s own entrypoint is used when empty. In a pool, makes a warm-exec pool (the script path is added as the last argument).
modefunction | stdinfunctionHow the agent calls your handler: handler(event) and its return value, or the event on stdin and the result from stdout.
runtime (alias agent)"python", "node", "go", or { agent = "/path" }guessed from entry: .py → python; .js .mjs .cjs .ts → node; .go → goWhich agent lives in the sandbox. A table names an agent of your own, by its path inside the sandbox. A built-in runtime needs an entry.
requirementspath—A requirements.txt, built once into a venv inside the image, mounted read-only at /venv, with /venv/bin first on PATH. Shared by everything with the same image and the same file.
systemlist[]apt packages ("libwebp7", "libpq5=16.4-1"), installed once into a derived layer of the image.
nixlist[]Accepted by the parser; not built: serving a function that sets it fails with a clear error.
workdirpath/appThe working folder inside the sandbox; / if the image has no /app.
useruid1000The uid inside the sandbox, mapped to your own uid on the host.

Isolation

FieldValuesDefaultWhat it does
isolationns | gvisor | vmnsWhere the wall is (chapter 6). ns is the only backend with warm functions and networking; gvisor (after zygo backend install gvisor) and vm run one-shot sandboxes and refuse the rest with a reason.
seccompdefault | strict | permissivedefault for a function, strict for a poolThe syscall allowlist (seccomp profiles). A pool with anything but strict gets a warning, because its zygotes are shared between tenants. Chapter 23’s T2 — contracted customers’ code — is strict, set here.

Limits

These are enforced for every request. None can be switched off, except timeout = 0, which needs --allow-unlimited.

FieldTypeDefaultEnforced byRules
membytes256Mmemory.max on each request’s own cgroup, and on the warm process’s; memory.high at 90%; no swapAt least 8M. Bounds one request: its whole process tree is killed together, and nothing beside it. A warm function may use mem once for its zygote plus once per concurrent request.
cpucores1.0cpu.max, over a 100 ms periodAbove zero. A request that spins is slowed, not the host.
pidsinteger64pids.maxNot zero. The fork-bomb limit.
timeoutduration30sthe supervisor, with cgroup.killNot zero unless --allow-unlimited. The request exits 137.
scratchbytesthe smaller of 64M and half of memthe size of the /tmp tmpfs; also the largest file a process may write; 10 000 files at mostMust be smaller than mem (it counts against it); a warning above half.
nofileinteger1024RLIMIT_NOFILE
io_read, io_writebytes per secondunlimitedio.max on the device behind the rootA warning when neither is set, except for zygo run.
connectionsinteger256the sandbox’s firewallNot zero. TCP connections a networked function may hold at once.
bandwidthbytes per secondunlimitedtraffic shaping in the sandboxNot zero. What the function may send (and receive, where the host has an ifb device). A warning when unset with egress or full, except for zygo run.

Network

FieldTypeDefaultWhat it does
networknone | egress | full | hostnonenone: its own loopback only, on which a function may listen. egress: only what allow names, plus DNS through Zygo’s own resolver. full: the public internet (bridge is accepted as another spelling). host: no network namespace at all; needs --allow-host-net.
allowlist of rules[]The egress allowlist. Only valid with network = "egress"; egress with an empty list allows nothing, and warns.

The forms an allow rule takes:

FormExampleMatches
host:portapi.stripe.com:443that name, that port
hostapi.stripe.comthat name, every port
*.domain:port*.example.com:443every subdomain of example.com, not example.com itself
CIDR:port203.0.113.0/24:5432that address range, that port
IPv6[2001:db8::1]:443, 2001:db8::/32brackets when a port follows

Private and link-local ranges — 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16 (the cloud metadata address), 100.64/10, 0/8, multicast and reserved (224/4, 240/4), ::1, fe80::/10, fc00::/7, ff00::/8 — are never reachable in a namespaced mode unless you pass --allow-private-net, and a rule inside one of them — a CIDR or a single address — is refused without it. network = "egress" or "full" together with seccomp = "strict" is refused too: strict takes socket away, so nothing could be reached.

Files, environment and secrets

FieldTypeDefaultWhat it does
mountslist of host:guest[:ro|rw][]Bind mounts, read-only unless :rw, and always nosuid,nodev. Both apply to every mount below the host path too. guest must be absolute. A host path cannot contain :. Two mounts cannot share a target.
envtable{}Environment variables for the sandbox. The zygote sees them, so never put a secret here.
secretslist of names[]Each name is read from the environment of the shell that runs serve/up (or the tenant store), and delivered as the file /run/secrets/<NAME> (mode 0400), only for the length of one request. A name cannot be in both env and secrets. In [runtime.<name>] the values come from the calling tenant’s store instead; see below.

A mount may not target /, /proc, /sys, /dev, /dev/pts, /dev/shm, /tmp, /run, /run/script or /work. These are Zygo’s own; a folder inside one, such as /tmp/cache, is fine.

Keeping it warm

FieldTypeDefaultWhat it does
concurrencyinteger4Requests running at once in one zygote. Up to four times as many more may wait; past that, the answer is busy: HTTP 429, CLI exit 75.
idle_timeoutduration10mAfter this long without a request, the zygote is paused: frozen, still in memory, woken in milliseconds by the next request.
cold_afterduration1hAfter this long, it is cold: the sandbox is dropped and the next request pays the warm-up again. A warning if shorter than idle_timeout.
  request ──▶ WARM ──(idle_timeout: 10m)──▶ PAUSED ──(cold_after: 1h)──▶ COLD
               ▲                              │                            │
               └────── next request: ~ms ─────┘                            │
               └────── next request: pays the warm-up again (~100s of ms) ─┘

[runtime.<name>]: a runtime pool

A pool is an interpreter and its dependencies, warmed with no code in it. Each request brings its own script, which is loaded in the forked child and gone with it. It is how one warm zygote serves thousands of different scripts (chapter 13). Every field above means the same thing here, except these:

FieldDefaultWhat it does
agent (or runtime)—Which agent the zygotes run: "python", "node", or { agent = "/path" }. A pool needs either agent or cmd, not both.
min_warm1Zygotes kept warm whatever the load; 0 counts as 1.
max_warmthe larger of min_warm and 4Zygotes the pool may grow to, one per second while every zygote is full. Cannot be below min_warm.
entryrefusedAnything warmed into a shared zygote would be forked into every tenant’s request.
seccompstrictUnless a layer sets it.
secrets[]Names a request may receive, never values: each request gets the calling tenant’s values from the tenant store, as /run/secrets/<NAME>, and has its zygote to itself while they exist. A tenant without one of the names is refused before anything runs; a pool naming secrets on a host with no store key is refused at serve. Never read from a shell. Chapter 14.

min_warm and max_warm are refused in [fn.*]. A pool runs concurrency × max_warm requests at once, and up to four times as many more may wait; past that it answers busy. zygo up starts only [fn.*]; a pool is started by zygo serve --runtime <name> or POST /runtimes, and stopped by zygo stop <name> or DELETE /runtimes/{name}.

[api]

FieldValuesDefaultWhat it does
listenIP:PORT or unix:///path127.0.0.1:7700Where zygo api listens. An IP address, not a host name: localhost:7700 is refused. --listen overrides it.
authbearer | nonebearerbearer needs ZYGO_API_TOKEN set, or tokens minted with zygo token. none is only accepted on a unix socket or a loopback address. --no-auth overrides it.

Flags that loosen, and zygo up

Three things are refused unless a flag says so: network = "host" (--allow-host-net), a private range in allow (--allow-private-net), and timeout = 0 (--allow-unlimited). The flags exist on zygo run, zygo serve and zygo mcp; zygo api has --allow-private-net for what its deploy callers serve. zygo up has none of them, on purpose: a spec that needs one has to be served deliberately, one function at a time, with the flag typed by a person.

Which fields have a flag

On run and serveOnly on runOnly on serveNo flag: file or API only
--mem --cpu --pids --timeout --scratch --nofile --isolation --seccomp --net --allow --mount --env --user --workdir --requirementsimage (positional), command → cmdhandler → entry, --image, --concurrency, --idle-timeout, --mode, --agent, --secret, --min-warm, --max-warmsystem, nix, io_read, io_write, connections, bandwidth, cold_after, cmd for a served function

zygo.lock

zygo up writes zygo.lock beside the spec, and it is meant to be committed. It records what the spec’s names pointed to on the machine that ran up: the image digest each function resolved to, the versions apt chose for its system packages, and the hash of its requirements file. It never changes what runs; it only refuses to let it change silently. It is saved only when something in it changed.

version = 1

[fn.api]
image  = "python:3.12-slim"
digest = "sha256:1f2e…"          # the multi-platform index: the same image everywhere
system = ["libpq5=16.4-1"]       # what apt really installed

[fn.api.requirements]
path   = "requirements.txt"
sha256 = "9ab3…"

digest is the multi-platform index’s digest when the registry has one, so it means the same image on every CPU type. For an image built for one platform only, it is that manifest’s digest, and a platform field says which.

SituationWhat zygo up does
No lock fileWrites one.
The spec changed: another image, another package list, an edited requirements fileRewrites that entry, silently — you asked for the change.
The spec is the same, but the image behind the tag movedRefuses, printing both digests. zygo up --relock accepts it.
The same packages resolved to other versionsRecords them, with a warning. apt does not keep old versions, so refusing would break every new host.
A function is gone from the specDrops its entry.
The file has a wrong version or cannot be readAn error that tells you to delete it.

Not built yet: pinning pip’s full dependency tree (only the requirements file’s hash is kept), pulling the locked digest, and a --frozen mode for CI.

  sandbox.toml ──▶ zygo up ──▶ resolves tags and packages ──▶ zygo.lock (commit it)
                                        │
             next `zygo up` ────────────┴──▶ same spec, different image? ──▶ refuse
                                                                 └─ --relock ─▶ accept

A complete example

[defaults]
image    = "python:3.12-slim"
mem      = "256M"
cpu      = 0.5
timeout  = "30s"

[fn.resize]                         # agent function, with dependencies
entry        = "./resize.py"
requirements = "./requirements.txt"
system       = ["libwebp7"]
mem          = "512M"
mounts       = ["./cache:/cache:rw"]
concurrency  = 8

[fn.parse]                          # warm-exec: a static binary, any language
image   = "alpine:3"
mounts  = ["./bin/parse:/app/parse:ro"]
cmd     = ["/app/parse"]
mem     = "64M"

[fn.fetch]                          # networked, with a secret
entry       = "./fetch.py"
network     = "egress"
allow       = ["api.stripe.com:443", "*.example.com:443"]
connections = 32
bandwidth   = "2M"
secrets     = ["STRIPE_KEY"]
timeout     = "10s"

[runtime.py312]                     # a pool for scripts that arrive per request
agent        = "python"
requirements = "./pool-requirements.txt"
min_warm     = 2
max_warm     = 8

[api]
listen = "unix:///run/user/1000/zygo-api.sock"
auth   = "bearer"

The files beside it

The example above names some files. Here they are, so you can see what a real project looks like on disk.

  my-project/
  ├── sandbox.toml
  ├── resize.py                 [fn.resize]  makes a small picture
  ├── requirements.txt          [fn.resize]  its Python packages
  ├── cache/                    [fn.resize]  a folder it may write to
  ├── fetch.py                  [fn.fetch]   calls an API with a secret
  ├── bin/parse                 [fn.parse]   a program you compiled (Go, C, …)
  └── pool-requirements.txt     [runtime.py312]  packages for the pool

resize.py gets a picture as base64 text, makes it at most 200 pixels wide, and sends it back as WebP. PIL comes from requirements.txt; the libwebp7 system package lets it write WebP.

# resize.py
import base64, io
from PIL import Image                     # installed from requirements.txt

def handler(event):
    picture = Image.open(io.BytesIO(base64.b64decode(event["image"])))
    picture.thumbnail((200, 200))         # at most 200 × 200, same shape
    out = io.BytesIO()
    picture.save(out, format="WEBP")
    return {"image": base64.b64encode(out.getvalue()).decode()}
# requirements.txt
Pillow

fetch.py reads its secret from a file — never from the environment — and calls the one host its allow list opens.

# fetch.py
import json, urllib.request

def handler(event):
    key = open("/run/secrets/STRIPE_KEY").read().strip()   # there for this request only
    request = urllib.request.Request(
        "https://api.stripe.com/v1/balance",
        headers={"Authorization": f"Bearer {key}"},
    )
    with urllib.request.urlopen(request, timeout=5) as answer:
        return json.load(answer)

bin/parse is any program that reads one JSON event on standard input and writes one JSON answer on standard output (chapter 6 shows how to build one in Go or C). The pool holds no file of yours: each request brings its script with it.

# word_count.py — sent with the request, not named in sandbox.toml
def handler(event):
    return {"words": len(event["text"].split())}
export STRIPE_KEY=sk_test_…      # secrets come from your shell
zygo up                           # warms resize, parse and fetch
zygo exec fetch '{}'
zygo serve --runtime py312        # pools are started by name
zygo exec --runtime py312 --script word_count.py '{"text": "one two three"}'

21. Environment, files and exit codes

Everything Zygo reads from its environment, everything it writes to disk, and every way it can end. Written from the code; if you script around Zygo, this is the chapter to keep open.

Environment variables Zygo reads

VariableRead byMeaningDefault
ZYGO_DATA_HOMECLI, supervisorThe data folder (same as --data-root).$XDG_DATA_HOME/zygo, else ~/.local/share/zygo
ZYGO_RUNTIME_DIRCLI, supervisorThe runtime folder: sockets and pid files.$XDG_RUNTIME_DIR/zygo, else /tmp/zygo-<uid>
ZYGO_LOGCLILog filter, e.g. debug or zygo=trace. Overrides -v. Logs go to stderr.warn
NO_COLORCLIAny value turns colour off. Colour is also off when output is not a terminal.
ZYGO_API_TOKENzygo api, SDKsThe bootstrap bearer token. Never a flag, because flags show in ps. It is removed from the environment of sandboxes the API starts.
ZYGO_API_URLSDKsWhere the API is: unix:///path, http://host:port or host:port.http://127.0.0.1:7700
ZYGO_SECRETS_KEYsupervisorThe 32-byte key of the secret store, as 64 hex characters or base64.none: secret routes refused
ZYGO_SECRETS_KEY_FILEsupervisorA file holding that key. Setting both is an error.
ZYGO_ALLOW_SHARED_UIDsupervisor1 lets tenants be registered on a host whose user has no subordinate uid range, where every tenant’s sandbox runs as the same host uid. Without it, POST /tenants is refused there (chapter 23).unset: refused
ZYGO_BYTECODEimage store0 turns off the Python bytecode layer.on
OTEL_EXPORTER_OTLP_ENDPOINTzygo apiSame as --otlp-endpoint.
OTEL_EXPORTER_OTLP_HEADERSzygo apiExtra headers for OTLP, as key=value,key=value.
DOCKER_CONFIGpull, loginWhere Docker’s config.json is, for registry credentials.~/.docker
ZYGO_KRUN_CONSOLEvm backendWrite the guest’s console to this file, for debugging.
ZYGO_IN_SCOPECLISet by Zygo itself when it re-runs inside a systemd scope, so it does not do it twice. Do not set it by hand.

Only on a Mac

VariableMeaning
ZYGO_LINUX_BINThe Linux build of Zygo to copy into the VM, before the ones Zygo looks for itself.
ZYGO_LIMA_TEMPLATEThe Lima template to create the VM from.
LIMA_HOMEWhere Lima keeps its VMs (~/.lima).

Every ZYGO_* variable in your shell is passed into the VM; so are the variables named by --secret and by secrets = [...] in the spec. Nothing else from your shell crosses.

Environment a sandbox receives

VariableWho sets itMeaning
ZYGO_FUNCTIONZygoThe function’s name. (ZYGO_TENANT holds the same value; the name is historical.)
HOMEZygo/tmp, unless the image or env sets one — so programs that write to ~ work on a read-only root.
ZYGO_REQUEST_IDthe agent, per requestThe request’s id.
ZYGO_DEADLINE_MSthe agent, per requestThe request’s time budget in milliseconds (its timeout); 0 means none.
TMPDIR, TMP, TEMPthe agent, per requestThe request’s own temporary folder: the workspace if one was sent, otherwise /work/tmp-<random>. Removed when the request ends.
ZYGO_AGENT_TMP_PARENTyou, for an agent’s testsWhere the reference agents make those folders instead of /work. Nothing in Zygo sets it.
ZYGO_WORKSPACEthe agent, per requestThe request’s workspace folder, when one was sent; the handler starts in it.
ZYGO_CHILD_SECCOMPZygo, under seccomp = "strict"The filter each forked child installs after GO, as base64 of a raw seccomp program. For the agent; a handler never needs it (chapter 24).
ZYGO_CHILD_SECCOMP_HELPERyou, for the Node agentThe path of the small shared object that lets a Node worker install that filter. Without it, the agent looks for zygo_child_seccomp.so beside itself, then falls back to Node’s permission model (chapter 18).
your envyouEverything in env / --env.

Secrets are not environment variables: they are files in /run/secrets/.

Files Zygo writes

~/.local/share/zygo/                    the data folder (ZYGO_DATA_HOME)
├── images/
│   ├── blobs/sha256/…                  compressed layers, as downloaded
│   ├── layers/<digest>/                unpacked layers, shared by every sandbox
│   └── index.json                      image name → manifest
├── cache/
│   ├── venvs/<key>/                    one venv per (image, requirements)
│   ├── flat/<digest>/                  flattened roots, where overlayfs can't be used
│   ├── system/                         records of derived apt layers
│   └── bytecode-failed/                bytecode builds that failed, not retried
├── scripts/  blobs/  deps/             what the API stores: scripts, tars, dependency sets
├── tenants/                            one JSON file per tenant
├── secrets/<tenant>.json               encrypted (ChaCha20-Poly1305); names only readable
├── tokens.json                         token hashes, never secrets        (mode 0600)
├── auth.json                           `zygo login` credentials           (mode 0600)
├── agents/                             the Python and Node agents, kept up to date
├── backends/                           gvisor/runsc · krun/Image (the vm guest kernel)
└── tmp/                                locks, and sandbox roots while they exist

$XDG_RUNTIME_DIR/zygo/                  the runtime folder (ZYGO_RUNTIME_DIR), mode 0700
├── supervisor.sock                     how the CLI talks to the supervisor (mode 0600)
├── supervisor.pid
├── host-report.json                    a short-lived cache of the host checks
└── tenants/<name>/agent.sock …         one socket per warm sandbox

Beside your project, zygo up writes zygo.lock, which you should commit. zygo doctor --fix may write ~/.config/systemd/user/user@.service.d/delegate.conf, /etc/sysctl.d/60-zygo-userns.conf, /etc/apparmor.d/zygo (the profile that lets this binary use user namespaces) and /etc/systemd/system/zygo-cgroup-favordynmods.service, and prints each one before it does. Putting pasta’s profile in complain mode runs aa-complain, which edits /etc/apparmor.d/usr.bin.passt or leaves a marker in /etc/apparmor.d/force-complain/. On a Mac, the VM lives in ~/.lima/zygo/. There is no Zygo config file: the spec and the lock are the only configuration.

Exit codes

  0 ─────── success
  1 ─────── a Zygo error, a failed check, a failed function in `up`, a failed batch line
  2 ─────── the spec is wrong  ·  `bench all`: no verdict, the host was busy
  4 ─────── `exec`: no such function
 75 ─────── `exec`: busy — every slot and the queue are full; retry later
111 ─────── (Mac) the VM could not be reached after three tries
125 ─────── this host cannot run the sandbox, or no supervisor is running
137 ─────── the program was killed: its deadline, or its memory limit
1–255 ───── `run` and `exec`: otherwise, the program's own exit code
CommandExit
runThe program’s code. 137 for a deadline or memory kill (use --outcome to tell which). 2 spec error, 125 host cannot run it, 1 other errors, including --pull never with no image.
execThe request’s code; 137 deadline; 75 busy; 4 unknown function; 125 no supervisor. --batch: 0 only if every line succeeded.
up1 if any function failed to start.
doctor0 if no check says FAIL. With --fix: 1 if you declined or a command failed.
bench0 within budget, 1 a budget missed, 2 (all only) no verdict.
agent test1 if any check failed.
supervisor status · stop1 if none is running · nothing to stop.
shellThe shell’s own code (130 if a signal ended it).
On a MacThe Linux command’s code; 128 + N if a signal ended it; 125 if the VM cannot be set up; 111 if it cannot be reached.

The outcome file

zygo run --outcome FILE writes, when the sandbox ends, why it ended. The exit code cannot say this alone: a deadline kill and a memory kill are both 137, and a judge or a CI step needs to know which. The file is written atomically — a temporary file, then a rename — so a reader never sees half of it. If Zygo fails before the program starts, the file is still written, with started: false and the step that failed.

{"exit_code": 137, "timed_out": false, "oom_killed": true, "peak_rss_kb": 65780,
 "wall_ms": 412.7, "plan_ms": 3.1, "start_ms": 9.4, "started": true, "phase": "run"}
FieldMeaning
exit_codeThe same number zygo run exits with.
timed_outThe deadline killed it.
oom_killedThe memory limit killed it.
peak_rss_kbThe most memory the sandbox used, in KiB.
wall_msHow long the program ran.
plan_ms · start_msTime spent planning (spec, pull, venv, network, root) · starting (up to execve).
startedWhether the program ever started.
phaseWhere it ended: plan, start, or run.

Ports and sockets

Zygo opens no port unless you run zygo api, which listens on 127.0.0.1:7700 by default. The supervisor listens only on its unix socket, which only your user can open. No sandbox mode accepts connections from outside.

22. Troubleshooting

This chapter is a list of things that go wrong, what each one looks like, and how to fix it. The error texts are kept word for word, so you can search this page for the line you see on your screen. Start with zygo doctor; most of what follows is doctor in longer form.

Start with zygo doctor

zygo doctor

zygo doctor checks this host for everything a sandbox needs. It does not just read a setting: it tries each thing, such as making a mount inside a user namespace, because a setting can say yes while the kernel says no. For anything missing it prints one line with the fix. Some fixes need root, and for those zygo doctor --fix can apply them for you: it prints every command and what it costs, and asks before it runs anything.

  ┌──────────────┐     all ok     ┌──────────────────────────┐
  │ zygo doctor  │ ─────────────▶ │ the host is fine: look   │
  └──────┬───────┘                │ at your spec or program  │
         │ a line says            └──────────────────────────┘
         │ FAIL / degraded
         ▼
  ┌──────────────────────────┐    ┌──────────────────────────┐
  │ read its remedy          │    │ zygo doctor --fix        │
  │ (this chapter has the    │ ─▶ │ shows each command and   │
  │  longer story)           │    │ its cost, asks, applies  │
  └──────────────────────────┘    └────────────┬─────────────┘
         ▲                                     │ or fix it by hand
         │                                     ▼
         │                        ┌──────────────────────────┐
         └─────────────────────── │ run zygo doctor again    │
                  until all ok    └──────────────────────────┘

How to read an error

A Zygo error names what failed, and usually says why and what to do. The first question is always who failed: the host, Zygo, or your program. The exit status answers that most of the time (see the table below). A second question is when it failed: before the sandbox started, or while your program ran. --outcome answers that one (below). If the error text is not clear, run the same command again with ZYGO_LOG=debug, which turns on Zygo’s own tracing.

  error on screen
        │
        ├─ exit 125 ───────────▶ the HOST cannot do it       → zygo doctor
        ├─ exit 2 ─────────────▶ your SPEC or flags are wrong → the message names the field
        ├─ exit 1 ─────────────▶ your INPUT or PROGRAM failed → read its stderr
        ├─ exit 137 ───────────▶ KILLED: time or memory      → --outcome says which
        ├─ exit 75 / 4 ────────▶ zygo exec: busy / no such function
        └─ exit 111 (macOS) ───▶ the Linux VM was not reached → retry, see below

Exit statuses that are Zygo’s

Every other status is your program’s own: it ran, and that is what it said.

statusmeaningwhere to look
1the input is wrong, for example a dependency build failedthe message; A dependency build failed
2the spec or the flags are wrongthe message names the field
4zygo exec: no such functionExit 4
75zygo exec: the function is at its concurrency limit; retryBusy
111macOS only: the Linux VM could not be reachedSession open refused by peer
125this host cannot run sandboxes, or there is no supervisor to talk toexit 125
137Zygo or the kernel killed it: the deadline or the memory limitExit 137

Host setup

These errors mean the machine itself is not ready. They come before your program runs, so your code is not the problem.

“this host cannot run sandboxes” (exit 125)

zygo doctor says which precondition is missing. The common ones are:

  • unprivileged user namespaces are turned off completely (kernel.unprivileged_userns_clone=0 on Debian and its relatives). A user namespace is the kernel feature that lets a normal user be “root” inside a sandbox and nobody outside it;
  • the kernel is older than 5.3.

Exit 125 also comes from a command that needs a supervisor when none is running; see “no supervisor running”.

“the program does not exist inside the image”, and it plainly does

Three causes, from most to least likely:

  • It is on your host, not in the image. Everything after the image name runs inside the sandbox. Mount the file in and name its path there: zygo run --mount ./hello.py:/hello.py:ro python:3.12-slim python3 /hello.py.
  • It is a dynamically linked binary and the image has the wrong libc. A dynamically linked program needs a loader from the C library to start. The kernel reports a missing loader as a missing program, so a glibc binary in alpine:3 fails with this exact message. Use an image from the same family as the binary, or a static binary.
  • The path is relative and the working directory is not what you thought. workdir defaults to /app, and falls back to / when the image does not have it. zygo run --workdir /where … sets it.

A dependency build failed (exit 1)

pip could not resolve a requirement, or apt could not find a package. The message carries the last forty lines of the build’s own output, and the reason is there.

The exit code is 1, not 125. The host is fine and the input is wrong, so a CI job that retries on another machine would fail there too. 125 is kept for a build that could not start.

“this host has no subordinate uid range for the user running Zygo”

POST /tenants (or client.create_tenant) was refused. Without a range in /etc/subuid and /etc/subgid, every sandbox maps to your one host uid, so two tenants’ sandboxes are the same user to the kernel and the wall between their files is only what the mounts and Landlock add. zygo doctor reports it as subuid/subgid … degraded and prints the fix, which is uidmap and a line per user:

sudo apt install uidmap
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
zygo supervisor stop            # the next command starts a new one

To run multi-tenant without that anyway — a test host, say — start the supervisor with ZYGO_ALLOW_SHARED_UID=1.

AppArmor and user namespaces

AppArmor is a set of rules, loaded by the system’s administrator, that the kernel checks for every process (see chapter 4). Ubuntu uses it to limit what a normal user may do inside a user namespace, and that gets in a sandbox’s way.

“applying a bind mount from the spec failed: No such file or directory”

On Ubuntu or Debian, when the path clearly exists, the cause is kernel.apparmor_restrict_unprivileged_userns=1. It lets a normal process create a user namespace, then refuses the first mount inside it. That mount is the first thing every sandbox does.

zygo doctor finds this by trying that mount, and prints the fix. zygo doctor --fix applies it: it prints every command and what it costs, and asks first. Where AppArmor 4 and apparmor_parser are installed — Ubuntu 24.04 has both — the fix is an AppArmor profile for the zygo binary, written to /etc/apparmor.d/zygo and loaded. It lets that one program use user namespaces and leaves the restriction on for everything else. It is attached by path, so a binary you move or install elsewhere needs --fix again. packaging/apparmor/zygo is the same profile for /usr/local/bin/zygo, for an image or a package to ship.

Where a profile cannot be loaded, --fix falls back to the sysctl, and writes /etc/sysctl.d/60-zygo-userns.conf so it survives a reboot. Read the threat model first: that one turns a protection off for every process on the machine, and --fix says so in those words above the question.

On a Mac, when the path is outside your home directory, the cause is different. The Linux VM mounts $HOME and nothing else, so a path elsewhere does not exist inside it. System temporary folders are the usual problem, because macOS puts them under /var/folders. Move the folder under $HOME. The shim (the small zygo program on the Mac that forwards commands into the VM) refuses every such path before forwarding, and names it: a mount, a spec or requirements file, a handler, or an --outcome file. An output file it let through would be written inside the VM, where you would never find it.

Cgroups

A cgroup is a kernel group of processes with limits on memory, CPU and process count (see chapter 3). Zygo must be allowed to make its own cgroups, which is called delegation.

“no cgroup controllers” or “there is no memory.max here”

Your shell is in a cgroup that cannot delegate. On a systemd machine, an ssh login sits in a session-N.scope that systemd owns, and a normal process may not create a cgroup inside it.

Zygo re-runs itself inside a new, short-lived systemd scope when it sees this, so the problem usually fixes itself. When a supervisor is running, zygo run hands the sandbox to it and never needs a scope at all. When neither works, try this:

systemd-run --user --scope -p Delegate=yes -- zygo run alpine:3 /bin/true

If that works and a plain zygo run does not, your systemd-run is refusing the delegation. loginctl enable-linger $USER is often the missing piece.

“A process of this unit has been killed by the OOM killer”, and the API is gone

zygo api was running under systemd. One request went over its mem limit, the kernel killed it inside its own cgroup — which is correct — and then every later request failed with connection refused. The journal for the unit says:

n8n-zygo-api.service: A process of this unit has been killed by the OOM killer.
n8n-zygo-api.service: Failed with result 'oom-kill'.

Systemd’s default OOMPolicy=stop stops a unit when any process in its cgroup is OOM-killed, and every sandbox’s cgroup is inside the unit’s. So the one request took the API, the supervisor and every pool down with it. The fix is one line in the unit, OOMPolicy=continue, then daemon-reload and a restart; for a transient unit, systemd-run -p OOMPolicy=continue …. Chapter 16 has the whole unit file. zygo doctor reports the unit’s setting:

systemd OOM policy   n8n-zygo-api.service: OOMPolicy=stop: one sandbox over its memory limit stops this unit, the supervisor and every pool with it   degraded

and zygo api prints the same warning when it starts inside such a unit. The same applies to any unit that ends up holding the supervisor, including one running zygo run for a long-lived sandbox.

Networking

A sandbox with a network uses pasta, a program that moves packets between the sandbox and the host as your own user, and nft, which sets up the firewall inside the sandbox (see chapter 4). Most network errors are one of these two tools being missing or blocked.

  ┌ sandbox ───────────────────────┐
  │ program ──▶ tap0 ──▶ nftables  │  needs /dev/net/tun, and nft installed
  └──────────────────────┬─────────┘
                         │
               ┌─────────▼─────────┐
               │ pasta (as you)    │  needs to be on PATH, and not blocked
               └─────────┬─────────┘  by the passt AppArmor profile
                         ▼
                 host's normal sockets

“pasta could not configure the sandbox’s network: Couldn’t open user namespace … Permission denied”

An AppArmor profile is holding pasta back and refusing it the sandbox’s user namespace. This is the distribution’s policy. It has nothing to do with /dev/net/tun, which is usually there and working.

sudo aa-status | grep -i passt
sudo aa-complain /usr/bin/pasta      # or: zygo doctor --fix

zygo doctor --fix offers this one too, when it finds the profile loaded and enforcing. It uses aa-complain rather than unloading the profile: the profile stays loaded and keeps logging what it would have refused. sudo aa-enforce /usr/bin/pasta puts it back.

Or use network = "none", the default, which needs no pasta at all.

“Couldn’t open PID file … Permission denied”

The same distribution policy, seen from the other side. Ubuntu’s passt AppArmor profile attaches by path to /usr/bin/passt, and to pasta, which is a link to it. It lets the program write files only where it expects, and Zygo’s pid file is in Zygo’s data folder, so it is refused. The profile is enforced by the host’s kernel, so this happens inside a container too, even one started with --security-opt apparmor=unconfined. dmesg shows apparmor="DENIED" operation="mknod" profile="passt".

sudo aa-complain passt                     # on the host
cp -L /usr/bin/pasta /usr/local/bin/pasta  # in an image: a path the profile does not name

The Zygo container image already does the second. Remove the /usr/bin/pasta link afterwards, so PATH cannot find the blocked one first.

“Failed to set up tap device in namespace”, or “did not finish configuring … within 10 s”

There is no /dev/net/tun, the device a sandbox’s network card is made from. Container runtimes allow the device but do not create the file for it, so this is the first thing a networked sandbox in a container says. zygo doctor reports it on the egress line. A run with a network now refuses early, rather than asking pasta: passt 2025_01 prints the line above and then never exits.

docker run --device /dev/net/tun …        # Docker
sudo modprobe tun                          # a host without the module

In Kubernetes, mount the node’s /dev/net/tun as a hostPath volume of type CharDevice. runc and crun allow the device by default.

“pasta is not on PATH”

sudo apt install passt nftables      # Debian, Ubuntu
sudo dnf install passt nftables      # Fedora

When these are missing, a networked sandbox does not start, rather than starting with no firewall. That is on purpose.

A name inside the sandbox does not resolve

Under network = "egress", the sandbox uses Zygo’s own name resolver, and a name that the allow list does not cover does not resolve. That is the allowlist doing its job. Add the name:

allow = ["api.example.com:443", "*.cdn.example.com:443"]

A private address is refused even with network = "full"

On purpose. Private and link-local address ranges — your host, its neighbours on the network, and 169.254.169.254 — stay refused in every namespaced mode unless you pass --allow-private-net. 169.254.169.254 is the cloud metadata address, and it is the first thing a compromised handler tries.

When a request fails

These are problems with one run or one request, after the sandbox started (or while it was trying to).

“[Errno 1] Operation not permitted”, naming a file that exists and is readable

The file is fine. A syscall was refused. A syscall is a request from a program to the kernel, and every sandbox runs under a seccomp allowlist: a filter that answers EPERM to any syscall the profile does not name (chapter 24). libc and Python report EPERM as “Operation not permitted”, against whatever path the call was about. So the traceback names the file, never the syscall. pip install --target once failed this way in nine tracebacks about RECORD and WHEEL; the refused call was listxattr, inside shutil.copy2.

Find out which syscall, in this order:

  1. --seccomp permissive, once. If it works there, the profile is the cause. permissive is default plus namespaces, mounts, ptrace and friends. It is not “no filter”, so a call refused under both is not a seccomp refusal at all.

  2. ZYGO_LOG=debug zygo run …. The launcher then asks the kernel to log every refusal, and each one shows up in the host’s kernel log as audit: type=1326 … comm="python3" … syscall=<n>:

    ZYGO_LOG=debug zygo run --mount ./out:/out:rw python:3.12-slim python3 -c 'import shutil; shutil.copy2("/etc/hostname", "/out/x")'
    sudo journalctl -k -n 20 | grep type=1326     # or: sudo dmesg | grep seccomp
    

    <n> is the syscall number for the sandbox’s CPU architecture. The tables in crates/zygo-core/src/backend/ns/syscalls.rs map it to a name.

  3. strace -f -e trace=%file on the program outside Zygo, when you cannot read the kernel log. It shows every file-related syscall the program makes, and the one missing from the profile is usually easy to spot.

  "Operation not permitted" on a file that is fine
        │
        ▼
  retry with --seccomp permissive ── works ──▶ the profile is the cause
        │ still fails                                  │
        ▼                                              ▼
  not a seccomp refusal (or the call      ZYGO_LOG=debug + journalctl -k
  is in no profile at all)                 → "type=1326 … syscall=<n>"
                                                       │
                                                       ▼
                                    look <n> up in syscalls.rs, then report it

Then report it. A syscall that a real package needs and the profile refuses is a bug in the profile. The extended-attribute family was one, until an early user found it.

Exit 137, and you cannot tell why

A deadline kill and an out-of-memory kill are both a SIGKILL, so both exit 137, and the wait status carries nothing else. Ask for the reason:

zygo run --outcome /tmp/why.json ...
cat /tmp/why.json

timed_out comes from the launcher. oom_killed comes from the kernel’s own counter. Over the HTTP API the same three fields are in the answer to POST /run, and the SDKs expose them as timed_out / timedOut and oom_killed / oomKilled (Elixir uses the Python names).

The sandbox never started, and it looks like the program failed

--outcome tells the two apart. A run that could not build its sandbox — the image is not there, the host cannot do it, a mount does not exist — writes the file too, with started: false and the phase that failed (plan or start). A program that ran and failed has started: true and phase: "run". Over the API the same two fields are in the answer to POST /run, and the SDKs expose started / phase. Decide on those fields, not on the error’s text: a sandbox that never started is unavailable, not the code’s fault.

  started: false, phase: "plan"   ─▶ the spec could not be turned into a plan
  started: false, phase: "start"  ─▶ the sandbox could not be built     } not your code
  started: true,  phase: "run"    ─▶ your program ran, and this is its result

“the supervisor did not start the sandbox within 30s” (exit 125)

When a supervisor is running, zygo run does not build the sandbox itself: it hands the run to the supervisor, which already sits in a delegated cgroup, and waits 30 seconds for the answer that the sandbox has started. Before it answers, the supervisor builds whatever a first run of that image still lacks: the Python bytecode layer, a venv from requirements, a system layer. On a fast disk that is a few seconds; on a slow one it is not. A Raspberry Pi 5 on an SD card took over a minute to compile python:3.12-slim, and the run was refused with this message while the build went on. A second run in that minute waits on the same build and is refused the same way.

The build finishes on its own, so the next run may simply work. To take it off the request path, zygo pull IMAGE builds the same layers ahead of time, with no budget on it; chapter 15 says which layers. A host with no supervisor running is not affected: there zygo run builds everything itself and waits as long as it takes.

If the message repeats for an image whose layers are all built, the supervisor is running but not replying. zygo logs says what it is doing; zygo supervisor stop ends it, and the next serve or up starts a fresh one.

“<name> is at its concurrency limit — retry” (HTTP 429, exit 75)

This is backpressure, not a failure: the request never ran. The function is at its concurrency limit and its queue is full. Retry after a moment, or raise concurrency if the function can really take more. Over the API it is HTTP 429. zygo exec exits 75, which is the same answer in the form a shell understands.

The SDKs raise this as its own type, Busy (in Elixir, a Zygo.Error with kind: :busy), so a caller can tell it apart from a handler that failed. A handler that raised will raise again; a Busy will not.

Exit 4: no such function

zygo exec exits 4 when the supervisor has no function by that name. A script can branch on it. Over the API a tenant gets the same single answer for “no such function” and for “that one belongs to somebody else”, because the difference between them is a fact about another customer. Check the name with zygo ps.

The handler raised and the traceback is missing

It is in the answer, not on your terminal. zygo exec prints the error. Over the API it is the error field, with stdout and stderr beside it. In the SDKs it is HandlerError, which carries both streams.

zygo logs resize --failed -n 20

A request sees state from a previous one

It should not, and this is worth reporting. Each request is a fresh fork() of the warm agent, so module-level state is whatever the zygote (the warm process every request is copied from) had at import time. Nothing a request writes survives it.

The one thing that does last is anything a handler writes outside the process: a file in a writable mount, a row in a database. That is your state, not Zygo’s.

Warm functions

A warm function is kept loaded by the supervisor, the background process that zygo serve and zygo up start (see chapter 13). These problems are about it staying up.

  warm ──(idle_timeout)──▶ frozen ──(cold_after)──▶ cold (dropped)
   ▲                         │                          │
   └──── one write to wake ──┘                          │
   ▲                                                    │
   └────────── next request pays a warm-up ─────────────┘

It keeps restarting

zygo logs <name> -n 50

An agent that crashes is warmed again automatically, with a growing pause between tries (a backoff). Repeated rewarming means the handler fails at import time. The zygote’s own output is in the log, above the requests.

It went cold on its own

That is idle_timeout and cold_after. A function past idle_timeout is frozen: it keeps its memory and costs one write to wake. Past cold_after it is dropped completely, and the next request pays a warm-up.

idle_timeout = "10m"
cold_after   = "1h"

zygo ps shows the state. To bring one back before a real request arrives:

curl -X POST .../fn/<name>/warm       # or client.warm(name) in the SDKs

1 request in 100 takes ~10 ms, and the rest take ~1.5

You see it in zygo bench warm or zygo stats: the usual request is fast, and the slowest 1 in 100 is several times slower. bench warm shows where the time goes, and says it: admit owns most of the slow request, followed by cgroup2 here has no favordynmods. zygo doctor reports the same thing as

cgroup moves   no favordynmods: ~1 warm request in 100 waits several ms to enter its cgroup   degraded

On Linux 6.0 and later, moving a process into a cgroup sometimes waits for the kernel to pass a quiet point. Only warm functions with an agent (Python, Node) and pools move their requests; warm-exec and one-shot runs are created inside their cgroup and do not wait. The fix is a setting of the whole machine:

zygo doctor --fix       # remounts cgroup2 with favordynmods, now and at boot

It prints the four commands and what they cost before it asks: every fork and exit on the machine gets slightly slower (about 2 µs usually, measured). On the Lima VM it took the slow 1 in 100 from 10.3 ms to 3.4 ms. Inside a container this is the host’s setting — doctor says so and does not offer to change it. To undo it, sudo systemctl disable zygo-cgroup-favordynmods and reboot. Chapter 25 has the numbers.

  slow 1 in 100 ──▶ zygo bench warm: most of it is `admit`?
                         │
                  yes ───┴──▶ zygo doctor: "cgroup moves … degraded"?
                                   │
                            yes ───┴──▶ zygo doctor --fix  (host-wide, asks first)

“tenant acme has no secret named STRIPE_KEY”

A runtime pool names secrets, and a call from acme arrived before that tenant had a value stored under one of the names. The call was refused before anything ran (400, bad_spec), because a pool’s values come from the calling tenant’s store and nowhere else — not from the shell, not from the pool’s own tenant. Store it, then call again:

zygo secrets set acme STRIPE_KEY          # or PUT /tenants/acme/secrets/STRIPE_KEY

A zygo exec --runtime call is the default tenant’s, so it reads default’s store. A pool that names secrets on a host with no ZYGO_SECRETS_KEY is refused at serve instead, and says so.

“no supervisor running”

Nothing is warm. The supervisor is started by zygo serve or zygo up, and it exits when the last function stops. zygo exec, ps, logs, shell, stats and top only ever connect to one that already exists. A command that needs one and finds none fails with no supervisor at <socket> and exit 125, and tells you to start one with zygo serve <handler> --name <name>.

The image store

Zygo keeps pulled images in its own local store, a folder of image layers (see chapter 15).

“image is not in the local store”

zygo pull python:3.12-slim

zygo run pulls on first use, as docker run does. zygo serve and zygo up do not: a deploy should not quietly depend on a registry being reachable.

“the image has moved” during zygo up

zygo.lock records the digest (the content hash) each image resolved to. The tag now points somewhere else, and Zygo stops rather than quietly running something different.

zygo up --relock      # accept the move and rewrite the lock

The store is using too much disk

zygo image prune --dry-run
zygo image prune
zygo image prune --unused-for 30d --blobs

Without flags, it removes only what nothing can reach: layers of images that were removed, and caches whose image is gone. --blobs drops the compressed copy of every unpacked layer. That roughly halves the store, and costs a download if a layer folder is ever lost.

The vm backend

The vm backend runs each sandbox in a small virtual machine with its own kernel (see chapter 12).

“the guest kernel is not installed”

zygo backend install vm

The guest kernel is a separate file for two reasons: it is GPL, while this binary is Apache-2.0, and it is twenty megabytes against a fifteen-megabyte size budget. From a checkout, make vm-kernel builds it, and zygo doctor reports it once it is in place.

A vm sandbox’s root is read-only

This host cannot build the guest’s private writable layer, so the sandbox fell back to sharing the image read-only. The log line starts with “no writable scratch for this guest” and says why. The layer needs rootless overlayfs, which is Linux 5.11 and newer; zygo doctor’s overlayfs (userns) line is the check. On a host that has it, a guest writes to / and /tmp freely, up to scratch, and nothing it writes reaches the shared image.

KVM GICv3 creation failed, falling back to KVM GICv2

Noise, not an error. On a host whose interrupt controller is GICv2 — a Raspberry Pi, for example — libkrun tries the newer one first and falls back. Guests boot either way.

On a Mac

On macOS, Zygo runs a Linux VM with Lima, and the zygo command on the Mac is a shim: it forwards each command into the VM over one shared SSH connection.

  Mac                                     Linux VM (Lima, "zygo")
  ┌──────────────────────────┐            ┌────────────────────────────┐
  │ zygo (shim)              │  one SSH   │ sshd  (MaxSessions: 64)    │
  │  checks paths are under  │ ─────────▶ │  ▼                         │
  │  $HOME, retries a        │ connection │ zygo (Linux build)         │
  │  refused session twice   │  many      │  ▼                         │
  └──────────────────────────┘  sessions  │ sandboxes, supervisor      │
   $HOME ═══════════════════ same path ══▶│ $HOME (and nothing else)   │
                                          └────────────────────────────┘

Everything is slow

Crossing into the Linux VM costs about 22 ms per command once the VM is up, over the SSH connection Lima keeps open. If every command costs 100 ms or more, that connection is not being used. ssh -F ~/.lima/zygo/ssh.config -O check lima-zygo should say Master running. The millisecond warm path is reached through the HTTP API or the SDKs, where the hop is paid once per connection rather than once per request. See what Zygo costs.

“limactl is not installed”

brew install lima

“the Linux build is missing”

make guest-build                       # compiled inside the VM; needs no Docker
make tests/linux/bin/zygo-linux-musl   # the same binary, built in a Docker container

That is the binary that runs inside the VM. A release ships it beside the Mac one. From a checkout it is one make, and the next zygo command copies it in.

“Session open refused by peer”, or exit 111

Every command forwarded into the VM is one session on one shared (multiplexed) SSH connection. The guest’s sshd limits the sessions one connection may carry (MaxSessions, ten by default). Past that, when many zygo commands ran at once — an adopter measured it at 24 — some were refused before the guest ran anything, with SSH’s own line mux_client_request_session: session request failed: Session open refused by peer and exit 255.

Three things stand against it now:

  • The shim retries a session the peer refused, twice, with a short pause before each try. It is the one layer that knows the guest ran nothing, so a retry is safe. What still fails after three attempts exits 111 with a sentence of Zygo’s, and SSH’s line is kept for -v. 111 differs from every status a program can produce and from 125, so a caller can branch on it.

  • The VM template raises the limit to 64, in the guest’s /etc/ssh/sshd_config.d/zygo.conf. That reaches a VM created from this release’s template and not one created before it, because Lima copies the template only once. zygo doctor reads the template generation back from the VM’s copy and says when it is behind.

  • For an existing VM, either recreate it — nothing under $HOME is in it, so you lose only warm functions and about a minute:

    limactl delete zygo      # the next zygo command builds a fresh one
    

    or apply the change by hand and keep the VM:

    limactl shell zygo -- sudo sh -c 'printf "MaxSessions 64\nMaxStartups 64:30:128\n" > /etc/ssh/sshd_config.d/zygo.conf && systemctl reload ssh'
    

make verify-shim-concurrency fires 24 commands at once for six rounds and wants 144 of 144 to succeed.

  attempt 1 ── refused ──▶ short pause ──▶ attempt 2 ── refused ──▶ longer pause
                                                                        │
      ┌─────────────────────────────────────────────────────────────────┘
      ▼
  attempt 3 ── refused ──▶ exit 111 "the Linux VM could not be reached"
      │
      └── accepted ──▶ the command runs, and its own exit status comes back

A command is refused because of where it was run

The VM mounts your home folder at the same path, and nothing else. A command run from outside $HOME is refused only when something in it depends on where it was run: a relative path — a mount, a handler file, -f, a script — or a sandbox.toml found by searching upwards from there, which the VM could not find. The message names the argument.

A command whose paths are all absolute and under $HOME, or that names no path at all, runs from anywhere: a server started from /, or a systemd unit with its default working folder. It runs in the VM’s /, so a relative path that slipped through would name a file that does not exist, rather than a file of yours that you did not mean.

“client speaks control v14, this supervisor speaks v13”

The supervisor in the VM is from the previous release. The shim replaced the binary, but a supervisor started from the old one was still running. The shim now stops it itself when it replaces the binary, and says so. If you see the message anyway:

zygo supervisor stop     # only the supervisor: it drains, exits, and the next serve starts a new one

zygo stop --all also works, but on a Mac it stops the whole Linux VM, and the next command pays the boot. It says so before it does it.

What doctor --json says

This is the document a health check reads:

{
  "checks": [
    {"name": "limactl", "status": "ok", "detail": "at /opt/homebrew/bin/limactl", "side": "host"},
    {"name": "vm", "status": "ok", "detail": "instance `zygo` is running", "side": "host"},
    {"name": "kernel", "status": "ok", "detail": "6.8.0-31-generic", "side": "vm"},
    {"name": "pasta", "status": "FAIL", "detail": "not on PATH", "remedy": "apt install passt", "side": "vm"}
  ],
  "backends": ["ns"],
  "ok": false
}
  • checks[]: one per probe. status is ok, degraded (usable, with a fallback), absent (an optional backend that is not installed) or FAIL. remedy is present when there is one. On a Mac, side says whether the check is the Mac’s (host) or the VM’s (vm); on Linux there is one side and no field.
  • backends[]: the isolation backends usable right now — the host has what each needs, and this binary implements it. On a Mac these are the VM’s.
  • ok: no check failed. The exit status is 0 exactly when ok is true. Both come from the same list, so you can trust either one alone. A stopped VM is ok: false: nothing can vouch for the sandboxes until it is up, and the remedy says so.

Still stuck

  • zygo run --dry-run --json <image> prints the resolved configuration, the mount plan and the cgroup values, and runs nothing.
  • zygo spec explain <fn> prints what a function resolved to, and where each value came from.
  • ZYGO_LOG=debug zygo <command> turns on Zygo’s own tracing.
  • zygo backend list says which isolation backends this host can really use, and why not the others.

If it looks like an escape — anything reaching the host from inside a sandbox — please report it privately. SECURITY.md says how, and what is in scope; chapter 23 has the short version.

23. Security: the threat model

A threat model is a written answer to three questions: what are we protecting, from whom, and what stands in the way. This one is written against what is actually built and measured, not what is planned. Where a control is not built, or is built but not yet checked, this chapter says so, because a threat model that claims too much is worse than none: it is what people plan around.

Reporting a vulnerability

If you find a way out of a sandbox, do not open a public issue. Report it privately, through GitHub’s private vulnerability reporting on the repository (Security → Report a vulnerability) or by e-mail. SECURITY.md has the address, what to include, how fast you will hear back, what is in scope and what is not. Zygo is pre-1.0, only the latest release is supported, and there is no bug bounty: reports are answered, fixed and credited, not paid for. No external audit has been done; one is planned.

Words used in this chapter

wordmeaning
tenantwhoever supplies the code that runs in a sandbox
escapecode inside a sandbox reaching something outside it
controlone lock that stands against an attack, such as a seccomp filter
vectorone way an attacker might try to get out
CVEa public, numbered record of a known security bug
LPElocal privilege escalation: a bug that lets a normal process become root
residual riskthe risk that is left after every control, and that you accept
backendhow Zygo isolates a sandbox: ns (namespaces), gvisor or vm

What is being protected

  • The host’s integrity: its files, its kernel, its other processes.
  • Other tenants: their code, their data, their secrets.
  • The supervisor itself, which holds every function’s spec and secrets.
  • The host’s resources: memory, CPU, pids, disk, network.

Who the attacker is

The attacker is a tenant who can run any code they like inside a sandbox. That is the design assumption, not the worst case. Running code you did not write is what Zygo is for. So every control below is judged by one question: what can hostile code, already running inside, do next?

The three trust classes

Not all code is equally hostile. The design sorts it into three trust classes, and gives each one a backend and a risk that is accepted.

ClassWhoBackendResidual risk accepted
T1Your own team, CIns, relaxed seccompA kernel CVE
T2Authenticated, contracted customersns + strict seccomp + Landlock + a network allowlistA kernel local-privilege-escalation CVE — historically a few critical ones a year
T3Anonymous, hostilevm — today one-shot only, no network, no in-guest cgroups, seccomp or Landlock; needs KVM and a make vm-build binaryA VMM or KVM CVE, which are much rarer

T2 is a setting, not the default. A function ships with seccomp = "default"; T2 means seccomp = "strict" in its [fn.<name>] table or --seccomp strict, an egress allowlist rather than full, and Landlock, which is on wherever the kernel has it. A runtime pool is strict by default, because its zygotes are shared between tenants (chapter 20).

  who wrote the code?            backend                     what could still break it
  ───────────────────            ───────                     ─────────────────────────
  ┌─────────────────────┐        ┌──────────────────────┐
  │ T1  your team, CI   │ ─────▶ │ ns, relaxed seccomp  │ ─▶ a kernel CVE
  └─────────────────────┘        └──────────────────────┘
  ┌─────────────────────┐        ┌──────────────────────┐
  │ T2  paying,         │ ─────▶ │ ns + strict seccomp  │ ─▶ a kernel LPE CVE
  │     known customers │        │ + Landlock           │    (a few critical a year)
  └─────────────────────┘        │ + network allowlist  │
                                 └──────────────────────┘
  ┌─────────────────────┐        ┌──────────────────────┐
  │ T3  anonymous,      │ ─────▶ │ vm                   │ ─▶ a VMM or KVM CVE
  │     hostile         │        │ (not complete today; │    (much rarer)
  └─────────────────────┘        │  see below)          │
                                 └──────────────────────┘

The vm backend today

The vm backend runs one-shot sandboxes. A guest boots, the program runs under a kernel of its own, and the root filesystem is read-only at the device, not by a mount option the guest could change. What it does not have yet: networking, warm functions, and its own in-guest cgroups, seccomp and Landlock. So a tenant’s limits are the VMM’s cgroup on the host side, and nothing finer. (A VMM, or virtual machine monitor, is the host program that runs the virtual machine.) Until those land, T3 workloads do not have the boundary the design gives them. The honest answer for anonymous code today is a separate machine.

The gvisor backend today

The gvisor backend runs one-shot sandboxes. gVisor runs its own kernel in user space, called the Sentry, so the program’s syscalls reach the Sentry instead of the host kernel. On a host without KVM, that is a smaller and easier-to-defend target than ns. make gvisor-linux checks that the same spec behaves the same way on both. It is not a T3 answer either: it holds no warm functions and has no networking. A rootless runsc cannot write cgroups, so its resource limits are advisory, and a tenant there can still use up the host’s memory.

The layers an attacker must pass

On the ns backend, hostile code has to beat every one of these locks to get out, or find a bug in the kernel underneath them all. Chapter 4 explains each lock.

  ┌─────────────────────────────────────────────────────────────────────┐
  │ host: the kernel, your files, other tenants, the supervisor         │
  │  ┌───────────────────────────────────────────────────────────────┐  │
  │  │ user namespace: root inside, nobody outside; uid_map fixed    │  │
  │  │  ┌─────────────────────────────────────────────────────────┐  │  │
  │  │  │ pid, mount, net, ipc, uts, cgroup namespaces            │  │  │
  │  │  │  ┌───────────────────────────────────────────────────┐  │  │  │
  │  │  │  │ cgroup limits: memory, cpu, pids.max always set   │  │  │  │
  │  │  │  │  ┌─────────────────────────────────────────────┐  │  │  │  │
  │  │  │  │  │ pivot_root, read-only root, no cgroupfs     │  │  │  │  │
  │  │  │  │  │  ┌───────────────────────────────────────┐  │  │  │  │  │
  │  │  │  │  │  │ no capabilities, no_new_privs, nosuid │  │  │  │  │  │
  │  │  │  │  │  │  ┌─────────────────────────────────┐  │  │  │  │  │  │
  │  │  │  │  │  │  │ Landlock (5.13+), nftables      │  │  │  │  │  │  │
  │  │  │  │  │  │  │  ┌───────────────────────────┐  │  │  │  │  │  │  │
  │  │  │  │  │  │  │  │ seccomp allowlist         │  │  │  │  │  │  │  │
  │  │  │  │  │  │  │  │    ┌──────────────┐       │  │  │  │  │  │  │  │
  │  │  │  │  │  │  │  │    │ tenant code  │       │  │  │  │  │  │  │  │
  │  │  │  │  │  │  │  │    └──────────────┘       │  │  │  │  │  │  │  │
  │  │  │  │  │  │  │  └───────────────────────────┘  │  │  │  │  │  │  │
  │  │  │  │  │  │  └─────────────────────────────────┘  │  │  │  │  │  │
  │  │  │  │  │  └───────────────────────────────────────┘  │  │  │  │  │
  │  │  │  │  └─────────────────────────────────────────────┘  │  │  │  │
  │  │  │  └───────────────────────────────────────────────────┘  │  │  │
  │  │  └─────────────────────────────────────────────────────────┘  │  │
  │  └───────────────────────────────────────────────────────────────┘  │
  └─────────────────────────────────────────────────────────────────────┘
     every layer is a feature of ONE kernel: a kernel LPE bug skips them all

How the controls are tested

Every row below marked attempted is run by make escape-linux. It runs the escape itself, not a check of a setting, because a test that reads a flag also passes on a kernel that ignores that flag. The suite attempts 21 vectors in 32 checks, and on Linux 6.8 reports 32 blocked, 0 escaped, 0 skipped. On 5.10 it skips one, the Landlock half of case 20, which needs a 6.7 kernel. Run rootless, it skips another: setting up a file capability to try needs root on the host.

Beside it, make fuzz-linux sweeps every syscall number the architecture has — 469 of them — against all three seccomp profiles. Each call is made in a forked child, so a syscall that blocks or exits takes nothing else with it. The escape suite tries the attacks somebody thought of; the sweep needs no imagination. That is what makes it the right check for a filter whose jump offsets are computed by a program. It checks that the profiles are ordered on a real kernel (permissive ⊋ default ⊋ strict), that no syscall kills the process, that clone3 answers ENOSYS rather than EPERM, and that every syscall named in the tables below is refused.

  make escape-linux                        make fuzz-linux
  ─────────────────                        ───────────────
  every known attack, really tried         all 469 syscall numbers
  → 32 blocked, 0 escaped, 0 skipped       × 3 profiles, one forked child each
                                           → profiles ordered, nothing kills
                                             the process, clone3 → ENOSYS

Vectors: the kernel and privileges

VectorControlStatus
Kernel syscall surfaceseccomp allowlist (default: ~215 syscalls named; 190 of them exist on aarch64, all on x86_64); bpf, io_uring, userfaultfd, keyctl, perf_event_open and ptrace refusedattempted — and swept: all 469 syscall numbers under each profile, 271 refused with EPERM under default (Linux 6.8, aarch64); the numbers above the table answer ENOSYS
mount() to reach the hostCAP_SYS_ADMIN dropped; seccomp refuses mountattempted
setns into the host’s namespacesrefused: no capability in the host’s user namespaceattempted
Regaining capabilities via a new user namespaceunshare(CLONE_NEWUSER) refused by seccompattempted
Rewriting uid_map to become another uidthe map is written by the parent and is then read-onlyattempted
Regaining privilege through a setuid or file-capability binaryone uid mapped, so setuid has no other identity to switch to; every mount nosuid; no_new_privs; an empty bounding setattempted — a copy of python3 given CAP_DAC_OVERRIDE reads a mode-000 file outside a sandbox and cannot inside; with the three controls switched off in a test build, it could
Reading kernel memory (/dev/mem, /proc/kcore)masked and not presentattempted
Creating a block device to read the host’s diskmknod refused; no block devices in /devattempted

Vectors: files

VectorControlStatus
Overwriting the runtime binary (CVE-2019-5736 shape)read-only root; /proc/self/exe is not writableattempted
cgroup release_agentcgroupfs is not mounted in the sandbox at allattempted
Writing through a read-only bind mountread-only for the mount and every mount below it: mount_setattr(AT_RECURSIVE) on 5.12+, one remount per submount (read from the host’s mount table) below thatattempted — including a tmpfs mounted inside the read-only source
Setuid binaries or device nodes in a shared folderevery bind, :rw as well, is nosuid,nodev, recursivelyattempted
Escaping a writable mount by symlinkpivot_root; the symlink resolves inside the sandbox rootattempted
Reaching the host’s filesystempivot_root with the old root detachedattempted
Tampering with shared image layersthe store is not reachable from inside; layers are bound read-onlyattempted

Vectors: processes and resources

VectorControlStatus
Seeing or signalling host processesseparate pid namespace; only the sandbox’s own processes are visibleattempted
Resource exhaustionmandatory cgroup limits; pids.max always set; memory.max and memory.oom.group on each request’s own cgroupattempted separately by make verify-linux (the fork bomb is cut off at pids.max; the memory hog is OOM-killed inside its own cgroup and the host loses 0 MB) and make verify-oom-linux (in a warm function, the hog dies and the three requests beside it, and the zygote, do not)
Zygote contaminationthe zygote never handles a request itself; every request is a fresh process that ends in _exitby construction
A file left in the temp folder for the next request (or tenant)each request’s TMPDIR is its own folder under /work, which cannot be listed, removed when the request ends; a literal /tmp/... path is still shared by the sandboxattempted (case 18, in a runtime pool)
A warm-exec program keeping the namespace descriptors its helper used, to setns back outthe helper marks every descriptor it renumbers close-on-exec before execve; setns is refused by seccomp as wellattempted (case 16)

Vectors: the network

VectorControlStatus
Reaching the host over the networkdefault network = "none"; under egress/full, RFC1918, CGNAT, link-local, loopback, multicast and reserved ranges are rejected above every allow rule, so a hostname that resolves into one is refused tooattempted by the supervisor suite, and measured from outside by the first consumer: under --net full the cloud metadata address, the host’s own Postgres and the LAN router are all no route, where Docker’s default bridge reaches two of the three (see below)
Using a resolver of one’s own to dodge the allowlistDNS is forced to one address; port 53 to anything else is rejectedattempted
A pooled script opening a TCP listener for the other tenants’ requests to reacha pool’s namespace is shared, so bind is handled and never granted there (Landlock, 6.7+), and strict, the pool default, removes the socket calls before that. A function’s own namespace has no such rule: nothing from outside reaches a port opened in it (ADR 0008)attempted (case 20: under strict, and under default where the kernel has Landlock’s network rules; below 6.7 the second is skipped)
Reaching a service the host bound to 127.0.0.1 only, through the sandbox’s own loopback or the gateway addresspasta is started with --tcp-ns none --udp-ns none --no-map-gw: it neither splices the sandbox’s loopback ports through to the host’s nor answers for the gateway itself. Before 0.1.4 both were on by default, and an allow rule for any name on port N also opened the host’s 127.0.0.1:N; below Linux 6.7, every loopback portattempted (case 19: under egress with the port allowed, under full, and by the gateway address with --allow-private-net; a host whose gateway reflects loopback ports back on its own, as a Lima VM’s does, skips the last)

Vectors: tenant against tenant

A runtime pool is one sandbox for many tenants: its zygotes are shared, and every process inside runs as one uid. The rows the suite attempts today are about one request reaching the traces of another. The rows it does not yet attempt are listed too, because a multi-tenant claim that rests on construction alone is not a tested one; the roadmap’s phase 5 is that work.

VectorControlStatus
Listing or reading another request’s workspace in a shared pool/work is a tmpfs of Zygo’s own, mode 0311, one folder with a random name per requestattempted (case 14b)
A handler leaving a link in its workspace, so that ?out=1 packs the supervisor’s filesthe workspace is walked by descriptor, openat with O_NOFOLLOW at every step, and a name is packed only if it is still a folder or a regular file when openedtested by zygo-core’s own tests, including a name swapped after the folder was read; not yet in make escape-linux
A file left in the temp folder for the next tenant’s requesteach request’s TMPDIR is its own folder under /work (above)attempted (case 18)
A pooled script rewriting its own file, which the next request will load/run/script is a read-only bind of a folder the supervisor owns on the host, so the answer is EROFS on every kernel; the child also checks the digest of the bytes it readattempted (case 17)
Tenant A running or reading tenant B’s script in the same poolscripts are bound read-only under /run/script/<digest>not yet attempted
Tenant A reading tenant B’s secret file while both requests are in flightsecret files are written per request, mode 0400, and removed afternot yet attempted as a cross-tenant case
Tenant A reaching tenant B’s process through /proc/<pid> in a shared poolone pid namespace and one uid for the whole pool; what one fork may read of another is what this row would testnot yet attempted
Tenant A reaching a service tenant B started on the pool’s loopbackone network namespace for the whole poolnot yet attempted
Tenant A at its pids limit, with a full scratch or a spinning CPU, slowing tenant Bper-tenant cgroups under zygo.slice/tenants/<id>not measured: no bound on B’s 1-in-100 time is published

Vectors: secrets and the supervisor

VectorControlStatus
Secretsnever in EXEC, never in the zygote: written by the supervisor from outside the sandbox to /run/secrets/<name> (0400) between FORKED and GO, removed when the last request in flight finishes. A warm-exec sandbox is reached through a directory descriptor its own init hands out before it hardens, not through /proc — which a non-dumpable process does not offer an unprivileged supervisor at allattempted — the file is absent between requests, the value is absent from the agent’s environ, and both paths are exercised as an ordinary user
The supervisor’s socketunix socket 0600 inside a 0700 directory, plus an SO_PEERCRED uid checkattempted
A request writing to the supervisor as its agent: a DONE for another request’s id, in a pool another tenant’sthe agent closes the child’s copy of its control socket before any request code runs (protocol §3, rule 13); a Node worker never holds ittested by the Python agent’s own suite, which has a handler write a forged DONE; not yet in make escape-linux
The HTTP APIbearer token from the environment only, compared in constant time; refuses to start unauthenticated on a reachable addressattempted

Out of scope

VectorControlStatus
Timing / microarchitectural side channelsout of scope—

Timing side channels, such as Spectre, let code learn secrets by measuring how long things take on a shared CPU. Tenants who need protection from that belong on the vm backend and on separate hosts.

The stated guarantee for egress

Egress is traffic from the sandbox out to the network. Under network = "egress" or "full", a sandbox cannot reach the cloud metadata endpoint (169.254.169.254), any RFC1918 address (the host, its neighbours, the LAN’s router), any CGNAT, link-local, multicast or reserved address, or the host’s loopback — whatever name they resolve from — unless the operator passes --allow-private-net. This is enforced inside the sandbox’s own network namespace by nftables rules that sit above every allow rule, and by a resolver that admits only what the allowlist names. The host’s loopback is closed a second way, because the firewall has to pass the sandbox’s own loopback: pasta is told not to carry the sandbox’s loopback ports to the host’s and not to answer for the gateway address (chapter 14). What Zygo does not guarantee is anything about the public internet under full: that mode means “the internet and nothing of yours”.

  sandbox wants to connect to …
        │
        ▼
  ┌───────────────────────────────────────────┐
  │ nftables rule 1 (checked first):          │
  │ 169.254.x, 10.x, 172.16-31.x, 192.168.x,  │── match ──▶ refused, "no route"
  │ CGNAT, link-local, loopback               │   (unless --allow-private-net)
  └─────────────────────┬─────────────────────┘
                        │ no match
                        ▼
  ┌───────────────────────────────────────────┐
  │ allow rules (egress: only the allow list; │── match ──▶ connected
  │ full: the public internet)                │
  └─────────────────────┬─────────────────────┘
                        │ no match
                        ▼
                     refused

Measured against Docker

This was measured, not read from the code, with a small connection probe on one host. The probe through Zygo’s --net full found no route to the metadata address, the host’s Postgres, the LAN router and 10.0.0.1, and reached 1.1.1.1:53. Docker’s --network bridge on the same host reached the host’s Postgres and the LAN router, and routed the metadata address (it was refused by the host, not blocked). Docker needs four iptables -I DOCKER-USER … -j DROP rules for the same result, one for each of 169.254.0.0/16, 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. Chapter 5 has more on Docker’s network.

destinationZygo --net fullDocker --network bridge
cloud metadata 169.254.169.254no routerouted (refused by the host, not blocked)
the host’s Postgresno routereached
the LAN routerno routereached
10.0.0.1no routenot reported
1.1.1.1:53reachednot reported

Where the boundary is weaker than it looks

This part is said plainly, because zygo doctor says it too. Each item is a place where the protection is thinner than the tables above might suggest.

ns is one kernel

Every control above is a feature of the kernel. A kernel local-privilege-escalation bug defeats all of them at once. That is the accepted residual risk for T1 and T2, and the reason vm exists for T3. zygo doctor says how old the running kernel’s series is, and warns past two years, because that risk grows with the gap. Age is not the same as unpatched: a long-term series gets fixes backported without changing its version, and the warning says which kind it is looking at. But a host four years behind is four years of hardening behind, and every control here rests on it.

Without newuidmap, tenants share a uid

newuidmap is the small setuid helper (from the uidmap package) that gives each sandbox its own range of user ids. With no such range, the id map falls back to a single entry, so two sandboxes run as the same host uid. They are then kept apart by namespaces and file modes, but not also by different uids. zygo doctor reports this as degraded.

Landlock needs 5.13, and its network rules 6.7

On older kernels the filesystem allowlist is absent, and the mount plan is the only filesystem boundary. The process-level network rules — bind refused in a runtime pool, connect limited to the allowlist’s ports under egress — are built and unit-tested, and enforced for real in one place: CI’s landlock-network job, on an ubuntu-24.04 runner (Linux 6.8, Landlock ABI v4), runs tests/linux/verify_landlock_net.sh (make landlock-net-linux runs the same script in a container). It picks the refusals nftables cannot produce: a bind() on a TCP port in a pool, which sends no packet, and a loopback connect() to a port off the allowlist, which the packet filter accepts on its first line. Both must fail with EACCES; a loopback connect on an allowed port must get past Landlock; a function must be able to listen on its own loopback, and under network = "none" connect to itself, with anything off loopback ENETUNREACH from the empty namespace. On a kernel below 6.7 the script says SKIP and exits 0 — which is why the job is not mixed into one that also runs on 22.04. Neither of this project’s own development machines can run it: Docker Desktop’s 5.10 kernel reports ABI 0, and the Raspberry Pi’s kernel has no Landlock at all. Below 6.7, the nftables allowlist inside the namespace is the only egress control, and it is the one every other check here runs against.

cgroup.kill needs 5.14

cgroup.kill is one file write that kills every process in a cgroup. Below 5.14, killing a request’s process tree falls back to freeze → signal → thaw. That has a race which the freeze closes, but it is more machinery than one write.

The seccomp profiles have been run against a sample, not a population

The compatibility matrix runs six Python packages, the standard library’s sqlite3 and three Node cases under default and strict. Each round of widening it has found something:

  • the filter refusing every thread;
  • strict refusing the agent its own control socket;
  • when Node was added, strict removing socketpair, which libuv uses for every pipe, so a strict Node function could not start a worker at all.

All are fixed and pinned by tests. But a control that has been run against ten cases has been run against ten cases.

The child filter is installed by the agent

Under strict, the agent’s forked child is locked down further: no execve, no new process. The agent does this, at the supervisor’s request, so it is only as good as the agent. zygo agent test now checks it rather than trusting it: an agent that ignores ZYGO_CHILD_SECCOMP and runs the request anyway fails conformance. The Python agent installs the filter. The Node agent installs it when the image has the helper object, and otherwise falls back to Node’s permission model, which is weaker against a V8 escape and says so in READY. The sh example refuses the request outright. Chapter 24 has the details.

On Ubuntu 24.04 and later, Zygo asks you to turn something off

kernel.apparmor_restrict_unprivileged_userns=1 stops a normal process from mounting inside a user namespace, which is the first thing every sandbox does. zygo doctor finds this by trying the mount, not by reading the sysctl. zygo doctor --fix then installs an AppArmor profile that gives the zygo binary alone the userns permission — the way Ubuntu lets its own browsers and container tools past the same rule — and the restriction stays on for every other process. Only where AppArmor cannot load a profile does it offer sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 instead. Be clear about what that is. It is a host-wide protection against a class of local privilege escalation that starts with an unprivileged user namespace, and turning it off removes it for every process on the machine, not only Zygo’s. It is the right call on a host whose job is running sandboxes, and it is what shim/lima.yaml does inside Zygo’s own VM. On a shared workstation, the profile is the right answer; packaging/apparmor/zygo is the file. Troubleshooting shows zygo doctor --fix, which applies it after asking.

Egress needs pasta and nft

If either is missing, a networked sandbox refuses to start. It does not start without a firewall. But that is a liveness failure — the service stops working — and you should know about it before it happens.

A derived system layer is built as root, with host networking

A derived layer is an image layer Zygo builds by installing system packages on top of an image. It installs them as root inside the sandbox’s user namespace, with host networking, once, before any tenant code exists. The package list is checked against the Debian package-name alphabet and passed as separate arguments, never through a shell.

What has not been reviewed

No external security audit has been done. That is the largest gap in this chapter, and it needs an auditor, not a commit. The hardening that could be done without one is in place: the full syscall sweep described above, and the kernel-age warning in zygo doctor.

Until an audit happens, the strongest honest statement is this: every vector marked attempted above is tried by a suite that runs on every change, none of them currently succeeds, the ones not yet attempted are marked as such, and the syscall surface they rest on is swept in full rather than sampled.

Hardening your deployment

These points come from SECURITY.md:

  • The ns backend leans on one kernel and does not hide it. For code that is hostile rather than semi-trusted, the wall is isolation = "vm" — today one-shot only, without a network or a warm path, and in a make vm-build binary on a host with KVM (the vm backend today). Until that is more, the honest answer for anonymous code is a separate machine.
  • Keep network = "none" unless a function really needs egress, and keep the allow list to the hosts it needs.
  • Do not run Zygo as root. It does not need it, and pasta, newuidmap and cgroup delegation all behave better without it.
  • Bind the HTTP API to loopback or a unix socket. It refuses to start without a token on a reachable address, but the safe default is worth keeping.
  • Install uidmap, so tenants get separate subordinate uid ranges rather than sharing one identity map. Zygo does not let a host become multi-tenant without one: registering a tenant is refused on a host whose user has no range in /etc/subuid, unless the supervisor was started with ZYGO_ALLOW_SHARED_UID=1, which is the operator saying they accept that every tenant’s sandbox shares one host uid.

Fork safety, question by question

A warm function answers each request with a fork() of a process that has already done its imports. That is where Zygo’s speed comes from, and it is the part people ask about first. This page answers those questions one at a time, and says plainly where the answer is “not fully”. Each answer links to the chapter with the detail.

  zygote: interpreter + your imports, never runs a request
     │
     ├── fork ──▶ child 1: GO ─▶ child filter ─▶ reseed ─▶ own TMPDIR ─▶ handler ─▶ _exit
     ├── fork ──▶ child 2: GO ─▶ …
     └── fork ──▶ child 3: GO ─▶ …
          ▲
          └── each child starts as a copy of the zygote, not of child 1 or 2

Is a forked child really clean?

Its memory is. A child starts as a copy of the zygote, and the zygote has never run a request. Whatever child 1 changes — a global, a monkeypatch, a cache — is in child 1’s own pages and is gone when it exits. Child 2 is a new copy of the zygote, not of child 1. A test holds this line for every agent (rule 4 of the protocol).

Its files need more than a fork, because every request in one sandbox sees the same filesystem. Each request gets its own temporary folder, named by TMPDIR and removed when it ends. But a literal /tmp/... path is one folder for the whole sandbox, and so is any writable mount you add. Write temporary files through tempfile or os.tmpdir(), never to a fixed path. Warm functions lists what a request sees.

What does a child share with the zygote?

Everything the zygote had when it was forked: the loaded modules, anything built at import time, and open descriptors. The pages are shared copy-on-write: they are only copied when one side writes. That is the point. Your import-time work is paid once and every request starts with it. It is also the rule to remember: what you build at import time, every request sees. Build clients, compiled templates and models there. Do not put per-user data there.

Does every child have the same memory layout (ASLR)?

Yes. ASLR places a program’s memory at random addresses, so an attacker cannot know where things are. A fork copies the layout rather than drawing a new one. So every child of one zygote has the same addresses, and an address leaked by one request holds for the next. The layout changes only when the zygote starts again, such as on a zygo up that replaces it or a serve after a stop. This is a real cost of the fork model, and it is why the ns backend is described as a wall for semi-trusted code. The Node agent is different: each of its workers is a new process with its own layout.

Can a secret end up in the zygote?

No. A secret is never in the environment, never in the EXEC message and never in the zygote. The supervisor writes it as a file, /run/secrets/NAME (mode 0400), from outside the sandbox, after the child exists and before it is told to start. The file goes when the function’s last request in flight finishes. Requests of the same function that run at the same moment can read it too: they have the same value, the same uid and the same folder. A request of another function cannot, because that function has its own sandbox. In a runtime pool, which many tenants share, a request that receives secrets has its zygote to itself while the files exist, so no other tenant’s child is forked beside them; the values are the calling tenant’s and are written the same way, never through the zygote. Chapter 14 has the details.

What about threads and locks?

A fork copies only the thread that called it. If another thread held a lock at that moment, the lock stays locked in every child. At warm-up the Python agent checks for threads that would survive a fork, Python threads and native ones. If it finds any, it stops forking and starts a fresh interpreter for each request instead: much slower, but correct, and zygo logs says it did. Start threads inside the handler, or lazily on first use. Chapter 13.

Do children draw the same random numbers?

Not from the usual places. The agent reseeds Python’s random, numpy’s global generator and torch’s generator in every child, before your code runs. os.urandom, secrets and uuid.uuid4() ask the kernel on every call, so a fork does not repeat them. What the agent cannot reseed is a generator you created at import time, such as RNG = random.Random(). Every request draws the same numbers from it. The agent names such a generator in zygo logs at warm-up. Create it inside the handler.

What about a connection opened at import time?

Every child inherits the same socket. Two requests running at once would then write into one connection, and the replies would mix. The agent does not detect this. Build the client at import time, which is where the cost is, and let it connect on first use in the child. Most clients, boto3 and requests.Session among them, connect lazily.

Can a request talk to the supervisor?

No. The zygote reaches the supervisor over one socket, and a forked child starts with a copy of it. The agent closes that copy in the child before any of your code runs, so a request cannot send messages as the agent. Without this, a request could answer for another request, which in a runtime pool may be another tenant’s. The Node agent’s workers never get the socket at all. The rule is in the protocol, and the agent’s tests try to break it.

Do atexit handlers run?

No. A child leaves with os._exit: no atexit handlers, no interpreter shutdown, and no flushing of buffers that the zygote also owns. Anything your handler must save, it saves before it returns.

Does the seccomp filter survive the fork?

Yes, and it cannot be removed. The kernel copies a process’s filters into its children, and nothing can take one away. The zygote runs under the sandbox’s filter, so every child does too. Under strict, each child also installs a second filter after GO and before any request code: no execve, no new process. Filters stack, and the strictest answer wins. That second filter is installed by the agent, so it is only as good as the agent. Chapter 24.

Can one tenant’s data reach another through shared pages?

Not through a function’s zygote. An agent function’s zygote holds that function’s code and nothing else, and the multi-tenant pattern is one zygote per script version (ADR 0005). A runtime pool is shared by tenants, so its zygote holds no tenant code at all. The script arrives with the request and loads in the child. Zygo sends it as a path to a read-only mount, which never passes through the zygote. The fallback, the script’s text in the message, does pass through the zygote’s memory, where the next tenant’s child inherits a copy. Zygo logs when it has to use that fallback. What a child writes stays in its own pages.

Why not snapshot and restore a microVM instead?

It is the same idea one level down: restore a whole VM from a snapshot instead of copying a process. It gives each call a hardware boundary, which ns does not. It also costs more: the projects that do it report around 10–20 ms per restore, against Zygo’s 1.4 ms fork. It has the same problems too: every restore of one snapshot starts with the same memory, random state included. Zygo’s vm backend runs one-shot sandboxes, and warm functions stay on ns (ADR 0002 says why, and what would change that). Similar projects compares the two.

Is the Node agent any different?

Yes. Node cannot be forked safely, so its agent keeps a few workers ready. Each worker is a new Node process that loads the handler itself, serves one request and exits, and a replacement starts off the request path. Nothing is inherited copy-on-write. So the thread, random-number and layout questions above do not arise. The cost is a little higher than a fork. The handler’s import-time code runs when the worker starts, before the request it will serve has its own cgroup. Chapter 13.

24. Seccomp profiles

Every Zygo sandbox runs under a seccomp filter, and you pick one of three profiles for it: default, strict or permissive. This chapter says what each one allows and refuses, which real packages have been tested under each, and how to choose. Chapter 4 explains what seccomp is, if you have not met it yet.

An allowlist, not a denylist

A syscall is a request from a program to the kernel: open a file, start a thread, make a socket. seccomp lets a process install a small filter that the kernel runs on every syscall, and that answers “allow” or “refuse”. Zygo’s filter is an allowlist: a syscall that is not named returns EPERM (“Operation not permitted”). The direction matters. A denylist names what is blocked, so it quietly gains a hole every time the kernel adds a syscall, and it adds several per release. An allowlist blocks a new syscall until someone decides to allow it.

  denylist (Docker)                      allowlist (Zygo)
  ─────────────────                      ────────────────
  "block these"                          "allow these"
  kernel adds new_syscall() ──▶ ALLOWED  kernel adds new_syscall() ──▶ refused
  until someone notices                  until someone chooses to allow it

Refused, or never heard of

A refusal comes in two forms, and the difference matters to a program. Zygo carries a table of every syscall in the Linux 6.10 headers. A syscall in that table that the profile does not allow answers EPERM: “this exists, and you may not”. A number above the table — a syscall newer than this build — answers ENOSYS: “there is no such syscall here”. That is the truth, and it is the one answer a C library falls back from. glibc tries fchmodat2 for chmod and uses the old call only on ENOSYS; on EPERM, python3 -m venv fails.

Each syscall that Linux 5.11 to 6.10 added was decided on its own. The ones that are a newer form of something already allowed are allowed: fchmodat2, epoll_pwait2, the futex_* family. So are Landlock and mseal, which only take power away from the caller. The new mount API, mount_setattr, pidfd_getfd, memfd_secret, cachestat, statmount, listmount and the lsm_* calls are refused. A test fails when the table grows past what has been decided.

  syscall number
  0 ─────────────────── in the table (≤ 462) ──────────────────┬──── above ────▶
  allowed by the profile ──▶ runs                               │
  in the table, not allowed ──▶ EPERM  "exists, and refused"    │ ENOSYS
                                                                │ "no such call"

The three profiles at a glance

You choose a profile per function with seccomp = "…" in sandbox.toml, or with --seccomp on the command line. The three are nested: strict is a subset of default, which is a subset of permissive. Anything outside all three is refused under every profile, permissive included.

  ┌───────────────────────────────────────────────────────────────────┐
  │ never allowed: anything in no list (a new syscall, most of the    │
  │ kernel's rarely used calls)                                       │
  │  ┌─────────────────────────────────────────────────────────────┐  │
  │  │ permissive = default + clone3, ptrace, unshare, setns,      │  │
  │  │   mount, pivot_root, chroot, mknod, process_vm_readv/writev,│  │
  │  │   personality … (Docker's default set)   NOT for tenants    │  │
  │  │  ┌───────────────────────────────────────────────────────┐  │  │
  │  │  │ default ≈ 215 syscalls   for everyone (T1, T2)        │  │  │
  │  │  │ clone only without CLONE_NEW*; ioctl minus TIOCSTI    │  │  │
  │  │  │  ┌─────────────────────────────────────────────────┐  │  │  │
  │  │  │  │ strict = default − socket, connect, bind,       │  │  │  │
  │  │  │  │   listen, accept4, ptrace, mount, umount2       │  │  │  │
  │  │  │  │   (+ child filter: no execve, no new process)   │  │  │  │
  │  │  │  └─────────────────────────────────────────────────┘  │  │  │
  │  │  └───────────────────────────────────────────────────────┘  │  │
  │  └─────────────────────────────────────────────────────────────┘  │
  └───────────────────────────────────────────────────────────────────┘
ProfileWhat it isWho it is for
default~215 syscalls: the set five reference packages exercise their real code paths under — numpy’s BLAS threads, Pillow’s codecs, pandas’ file I/O, pydantic’s Rust core, requests’ TLS setup. clone is allowed only with every CLONE_NEW* flag clear, so a sandbox cannot make a namespace; ioctl is allowed except for TIOCSTI and its relatives. bpf, io_uring_*, userfaultfd, keyctl, perf_event_open, ptrace, mount and unshare are absent.Everyone (T1, T2)
strictdefault minus the calls that reach the network — socket, connect, bind, listen, accept4. (Its source also names ptrace, mount and umount2, as a guard should default ever gain them; today it has none of the three.) In the agent’s forked child, additionally minus execve, execveat, fork, vfork and any clone without CLONE_THREAD (see the child filter).A network = "none" function whose author wants the kernel to refuse a socket, not merely the namespace to have nothing behind it — and every runtime pool by default, because a pool’s child runs a script that arrived over an API. With network = "egress" or "full" it is refused, since nothing could be reached
permissivedefault plus clone3, ptrace, unshare, setns, mount, pivot_root, chroot, mknod, process_vm_readv/writev, personality and the rest of Docker’s default profile. Those eight are the only appendix-B exclusions it grants, and a test asserts the list. It is not “no filter”: a syscall outside all three lists is refused under permissive too.Debugging a package the tighter profiles break, and Zygo’s own derived-layer builds, where dpkg uses the legacy chown/chmod/mknod calls. Not a tenant profile.

(T1 and T2 are the trust classes from chapter 23: your own team, and known, paying customers. “Appendix B” is the design’s list of syscalls a sandbox should never need.)

default

default is what a function gets when its author does not choose. It names about 215 syscalls (216 in the source; 190 of them exist on aarch64 and all of them on x86_64, and a name the kernel does not have is left out of the filter), found by running five reference packages through their real work: numpy’s BLAS threads, Pillow’s image codecs, pandas’ file I/O, pydantic’s Rust core, and requests’ TLS setup. clone, the call that makes a new process or thread, is allowed only when no CLONE_NEW* flag is set, so a sandbox cannot make a new namespace. ioctl is allowed except for TIOCSTI and its relatives, which could push keystrokes into a terminal. bpf, io_uring_*, userfaultfd, keyctl, perf_event_open, ptrace, mount and unshare are not in it; each is a large, complex part of the kernel that has had serious bugs.

strict

strict is default without the calls that open network connections: socket, connect, bind, listen and accept4. Its list also names ptrace, mount and umount2, so that they stay out if default ever gains them; today default has none of the three. It is for a network = "none" function whose author wants the kernel itself to refuse a socket, not just a namespace with nothing behind it. It is also the default for every runtime pool, because a pool’s child runs a script that arrived over an API. In the agent’s forked child, strict also removes execve, execveat, fork, vfork and any clone without CLONE_THREAD; the child filter explains how.

permissive

permissive is default plus clone3, ptrace, unshare, setns, mount, pivot_root, chroot, mknod, process_vm_readv/writev, personality and the rest of Docker’s default profile. Those eight are the only appendix-B exclusions it grants, and a test checks the exact list. It is for debugging a package that the tighter profiles break, and for Zygo’s own derived-layer builds, where dpkg uses the old chown/chmod/mknod calls. It is not a tenant profile.

permissive is not “seccomp off”

A reader once used this profile wrongly and drew the wrong conclusion, so it is worth saying plainly. permissive means “the default plus namespaces, mounts, ptrace and friends” — the set Docker’s default profile allows. It is not “no filter”, and it is not the “turn seccomp off” step when you debug. An early user tried --seccomp permissive against a failure whose cause (listxattr, below) was in no list, saw no change, and decided the flag did nothing. It did work; the syscall was simply missing from all three profiles. If something fails under permissive too, follow the troubleshooting entry for EPERM, which shows how to find the syscall.

Seeing which profile applies

zygo run --dry-run prints the resolved profile, where the choice came from, and how many syscalls it names. So you can see that the flag took effect without running anything.

The extended-attribute family

Extended attributes are small name–value labels stored on a file, next to its normal owner and mode. Every profile allows the whole family — getxattr, listxattr, setxattr, removexattr and their l/f forms — since an early user found listxattr missing. shutil.copy2 calls it, and pip install --target is one copy2 per file, so a profile without it broke every Python package install into a mounted folder, with a traceback about RECORD.

strict keeps them on purpose. Reading and listing attributes on a filesystem the sandbox owns leaks nothing. A user.* write is bounded by the mount. The kernel refuses trusted.* and security.* to an unprivileged uid before any filter is asked. And a network = "none" function copies files like any other. A unit test holds all twelve calls under all three profiles, and make verify-seccomp-profiles-linux does the copy2 and the pip install on a real kernel.

How to choose

  Is the code from someone you do not know, arriving over an API?
     │
     ├─ yes ─▶ strict   (the default for a [runtime.<name>] pool)
     │
     └─ no ──▶ Does the function need no network, and do you want the
               kernel itself to refuse sockets?
                 │
                 ├─ yes ─▶ strict
                 │
                 └─ no ──▶ default   (the default for a [fn.<name>])
                             │
                             └─ a package breaks under default?
                                  ▶ try permissive ONCE, on your own machine,
                                    to find the cause; then report the syscall.
                                    Never hand permissive to a tenant.
you wantprofile
a normal function, your own or a known customer’sdefault
a function with network = "none" that must not even make a socketstrict
a runtime pool running scripts from an APIstrict (already the default)
to find out whether seccomp is why a package failspermissive, once, then report
to build a derived system layer with dpkgpermissive (Zygo does this itself)

The compatibility matrix

make seccomp-matrix-linux installs six packages into one venv inside the image, then imports and uses each one under every profile. Then it does the same for Node, in a second container, because a matrix built only from CPython says nothing about a runtime that talks to the kernel differently. Every cell is an attempt: the package is made to do the thing people use it for, and the cell is what the sandbox returned.

The third column is the same code again, run as a script in a runtime pool: one warm zygote holding no tenant code, the script written into the sandbox per request, and strict as the pool’s default rather than something the caller asked for.

packagedefaultstrictpool (strict)
requests 2.32.3worksworks — the socket is refused with EPERM, which requests reports as its own ConnectionError; nothing hangsworks
httpx 0.27.2worksworks — refused at client construction rather than at send, and reported as its own errorworks
pydantic 2.9.2worksworksworks
numpy 2.1.3worksworksworks
pandas 2.2.3worksworksworks
Pillow 11.0.0worksworksworks
sqlite3 (stdlib)worksworks — on disk, so fcntl locking, fsync and ftruncate are all exercisedworks
Nodedefaultstrictpool (strict)
worker_threadsworksworks — a thread is a clone with CLONE_THREAD, which the child filter permitsworks
crypto + zlib + fsworksworksworks
child_processworksrefused, which is the point of the profilerefused

Measured on Linux 6.8 (aarch64, the Lima VM) on 26 September 2026, with the same result as on Linux 5.10 (Docker Desktop), where it was first run. “Works” means the handler returned {"ok": true} from a real operation — a validation error caught, a mean over a million numbers, a CSV grouped, an image resized and encoded, a worker thread joined — not just that the module imported.

Checking the profiles themselves

make verify-seccomp-profiles-linux is the short form of the same idea, for the profiles rather than the packages. From inside a sandbox, unshare succeeds under permissive and returns EPERM under default, and socket succeeds under default and returns EPERM under strict. The unit tests prove the lists differ; this proves the sandbox does. make fuzz-linux is the long form: every syscall number under every profile (see chapter 23).

  unit tests                 the lists in the code differ
  verify-seccomp-profiles    a real sandbox answers differently per profile
  seccomp-matrix             real packages do real work under each profile
  fuzz-linux                 all 469 syscall numbers × 3 profiles

What the matrix found on its first run

The first run of the matrix found two bugs that made whole profiles useless. Both are fixed, and tests now hold the fixes in place.

Every threaded program was dead under default

clone3 was only in permissive, so under the other two profiles it returned EPERM, like any other unlisted syscall. glibc’s pthread_create tries clone3 first and falls back to clone only on ENOSYS; on EPERM it gives up, and the program sees RuntimeError: can't start new thread. pip starts a thread for its progress bar on any download over a few megabytes. That is how the venv build of this very matrix failed at numpy’s 13.6 MB wheel — and numpy itself starts several threads on import. The profile had been “validated” against these packages earlier, but with a JSON profile applied by a different tool, not with the filter that ships.

clone3 cannot simply be allowed. It takes its flags in a struct in memory, which the filter cannot read, so a profile that checks clone’s namespace flags cannot let clone3 through unchecked. It now answers ENOSYS, which is what Docker’s profile does for the same reason, and glibc takes the clone path, where the flags are checked.

  pthread_create
     │
     ▼
  clone3(...) ──▶ filter ──▶ EPERM  ──▶ glibc gives up: "can't start new thread"   (before)
                         └─▶ ENOSYS ──▶ glibc falls back
                                          │
                                          ▼
                                   clone(flags) ──▶ filter checks: no CLONE_NEW* ──▶ ok

strict killed every function before its handler ran

The first version of strict removed the whole socket family, including the calls that only move data: sendto, recvfrom, sendmsg, setsockopt. But the agent talks to the supervisor over a socket it inherited, and a socket it cannot recvfrom is a supervisor it cannot hear. So every strict function died at start-up with “expected READY from the agent, got end of stream”. Moving bytes on a descriptor a process was handed is not a new power; opening one is. strict now removes creation and keeps transfer, and a unit test holds the line in both directions.

The child filter

The sandbox’s own filter cannot remove execve. The launcher installs the filter just before it execs the agent, so a profile without execve would be a sandbox that cannot start. So that extra lock goes where it belongs: in the agent’s forked child, which is already running the interpreter and never needs another program.

Under strict, the supervisor builds a second filter program (STRICT_CHILD_REMOVED plus a flag check on clone). It hands it to the agent as ZYGO_CHILD_SECCOMP: base64 of the raw struct sock_filter array, so an agent in any language can install it without knowing a syscall number. The reference agent decodes it once at start-up. Then, in every child, after GO and before the handler, it installs it with one prctl(PR_SET_SECCOMP). Filters stack, and the kernel takes the strictest answer, so the sandbox’s own filter stays in force underneath.

  sandbox (seccomp: strict)
  ┌───────────────────────────────────────────────────────────────────┐
  │ agent / zygote        reads ZYGO_CHILD_SECCOMP once at start-up   │
  │    │ fork() per request                                           │
  │    ▼                                                              │
  │ child ── GO ──▶ prctl(PR_SET_SECCOMP, child filter) ──▶ handler   │
  │                                                                   │
  │ the child now has TWO filters; the kernel takes the strictest:    │
  │   sandbox filter  (strict)                                        │
  │ + child filter    (no execve, execveat, fork, vfork,              │
  │                    no clone without CLONE_THREAD)                 │
  └───────────────────────────────────────────────────────────────────┘

What the child can and cannot do

The child cannot execve or execveat: no new program. It cannot fork, vfork, or clone without CLONE_THREAD: no new process. So a handler cannot fork-bomb its way up to pids.max, and subprocess.run fails with PermissionError at the clone (or at the execve, on a platform whose libc uses vfork). It still can start a thread, which is a clone with the flag. An agent that cannot install the filter fails the request rather than running it unfiltered. A malformed value is a start-up error that the supervisor sees.

Which agents honour it, and how

The Python agent installs it directly: ctypes, one prctl, decoded once at start-up. The Node agent cannot do that. Node’s standard library has no FFI (a way to call C functions), and the filter must go in after the last execve, so a launcher that installs it and then execs cannot work either. So the Node agent does one of two things, and says which in READY:

  • seccomp — it loads agents/node/zygo_child_seccomp.c, forty lines built with one cc command, through process.dlopen. Its constructor installs the filter before the “not a Node addon” error is thrown. The worker checks it by reading Seccomp_filters from /proc/self/status, and refuses to run the request if the count did not go up. This is the real thing: a kernel filter that survives any code running inside the worker.
  • node-permission — the image has no helper object, so the worker runs under Node’s own permission model instead: no child processes, no native addons, no WASI, and worker threads still allowed, because the filter it stands in for permits a clone with CLONE_THREAD. It removes what strict asks to remove, but it is Node enforcing it, not the kernel, so a V8 escape gets past it where it would not get past a filter.

The sh example agent does neither, and refuses every request under strict. That is the protocol’s other correct answer. zygo agent test checks all of this rather than trusting it: it starts a second copy of the agent with ZYGO_CHILD_SECCOMP set, asks the handler to start a program, and fails an agent that runs the request anyway.

agentunder strictenforced by
Pythoninstalls the child filter with ctypes + prctlthe kernel
Node, helper object in the imageloads zygo_child_seccomp.c, checks Seccomp_filters; READY says seccompthe kernel
Node, no helper objectNode’s permission model; READY says node-permissionNode (weaker against a V8 escape)
sh examplerefuses every request—

strict is the default for a runtime pool

A [fn.<name>] gets default unless its author says otherwise, and a [runtime.<name>] gets strict. The difference is who the child is. A function’s child runs code that the function’s own author deployed, and default is the profile that author chose by not choosing. A pool’s child runs a script that arrived over an API, from somebody who may never have met the operator.

A pool may name a lower profile — an operator may know their tenants — and then resolution prints a warning saying what that allows. The pool column of the matrix above is the same packages run as scripts in a pool, so this default is measured, not just claimed.

The filter goes on before the script’s first line

The filter is installed before the script’s first line, not only before the handler is called. A script’s module body is request code too, and an agent that loaded it first would give a strict pool nothing. zygo agent test --script-spawn <file> is the check: a script whose module body starts a program, run once without the filter to prove it can, and once under it to prove it cannot.

The Rust and Python test suites, make verify-supervisor-linux (a subprocess.run under default and then under strict, and a thread under strict), and every strict cell of the matrix above all run the child under the child filter.

What is not done

  • The packages people will ask about next are not in the matrix: anything with a JIT (numba, PyTorch), anything that opens a browser, and on the Node side sharp and axios. Add a row before relying on the answer.
  • permissive grants eight of appendix B’s exclusions. That is what the profile is for — it is how dpkg builds a derived layer — but it means permissive is an operator’s debugging tool, not something to hand a tenant. A test checks the exact list, so one more joining it is a decision, not a detail. io_uring_setup, io_uring_enter, io_uring_register and userfaultfd were in that list until the escape suite started trying every appendix-B attack against all three profiles and found them reachable.

25. What Zygo costs: performance

This chapter lists every number Zygo publishes about itself: how long a request takes, how much memory a warm script uses, and where the time goes. Each number was measured by a command in this repository, on a real kernel, and each one names the machine it came from. You can run the same commands and check them.

The short version

All numbers were measured on 25 September 2026, on the code at commit 9607289, on the Lima VM unless marked. The machines says why that machine.

  what one request costs, usually (the median), on the Lima VM unless marked
  ──────────────────────────────────────────────────────────────────────────
  warm function (a fork)          1.44 ms  ▌
  runtime pool (a fork)           1.91 ms  ▌
  warm-exec (a new process)       1.43 ms  ▌
  one-shot sandbox                12.3 ms  █
  vm backend, one-shot (Pi 5)      422 ms  ████████████████████████████████████████
  ──────────────────────────────────────────────────────────────────────────
  one █ is about 10.5 ms
Usually1 in 100Where it was measured
A warm request1.44 ms10.5 msLima VM
A warm request from a pool, a different script each time1.91 ms11.4 msLima VM
A warm-exec request1.43 ms4.3 msLima VM
Sustained throughput through one warm function1,108 requests a secondLima VM
A one-shot sandbox, image already pulled12.3 ms15.3 msLima VM
A one-shot sandbox under a hardware boundary (vm)422 msRaspberry Pi 5
One more warm script, one zygote each11.1 MBLima VM
One more warm script, in a runtime pool0.0 kBLima VM

The “1 in 100” column is much higher than the usual one for the warm function and the pool. That is a known kernel effect on Linux 6.x, not noise: with the favordynmods setting zygo doctor --fix offers, both fall to about 3.4 ms (why).

The rest of the chapter explains each line, and the embedder’s benchmark compares them with what you would do without Zygo.

Words used in this chapter

A few words come up again and again. Each is simple once named.

WordWhat it means
p50 (the median)Sort all the request times. The one in the middle is p50: half the requests were faster, half slower.
p90, p99The time that 90 (or 99) requests out of 100 beat. p99 shows the slow “tail” that a few unlucky requests see.
minThe fastest single request.
overheadThe time Zygo adds around your code, with your code’s own time taken out.
throughputHow many requests are finished per second, when many are sent.
RSS (resident set size)The memory pages a process holds in RAM, counting shared pages in full for every process that shares them.
PSS (proportional set size)The same, but each shared page is split between the processes that share it. Two processes sharing one page count half each.
throttlingWhen a cgroup’s CPU limit is reached, the kernel stops the process until the next time slice. The CPU of a hot machine can also slow itself down; that is thermal throttling.
zygoteA warm process that already started the interpreter and loaded the code. Each request is a fork() of it (chapter 6).

Why p99 and not just the average? Because the average hides the slow requests, and a user who waits for the slow one does not care about the average.

  100 requests, sorted from fastest to slowest (each bar is one request's time)

  request   1  ██████████
  request  25  ███████████
  request  50  ████████████          ◄── p50: half of the requests were faster
  request  75  █████████████
  request  90  ███████████████       ◄── p90: 9 in 10 were faster
  request  99  ███████████████████   ◄── p99: 99 in 100 were faster
  request 100  ████████████████████████████████   the slowest; p99 ignores it

The machines

Every millisecond in this book came from one of these three machines.

Lima VM (the main one)Raspberry Pi 5Docker Desktop’s VM
Hardware2 vCPU, 4 GiB, of an Apple M1 Max4× Cortex-A76, 8 GiB, aarch645 vCPU, 8 GiB, of the same Mac
OS and kernelUbuntu 24.04, Linux 6.8Ubuntu, Linux 6.5LinuxKit, Linux 5.10
How Zygo ranan ordinary user under a systemd login, the binary on the VM’s own diskan ordinary user, under a systemd session, everything in RAM (/dev/shm)a privileged container, as root
What is measured herealmost everything: warm path, pool, warm-exec, one-shot, throughput, density, the embedder’s benchmark, bytecode, dependenciesthe vm backend, warm-up times, and the older embedder’s table with kerna check of the warm path on an older kernel

Why Lima. Until 25 September most numbers came from Docker Desktop’s VM. Re-measured that day, it had become twice as slow at one-shot sandboxes (40.7 ms against the 18.4 ms once published) — and a build of Zygo from 20 September was just as slow there, so the machine had changed, not the code. Lima is closer to a real host: a normal Linux, a normal user, overlayfs. On Docker Desktop’s VM, the same day, the warm path was 1.55 ms usually and 2.60 ms for 1 in 100; the pool 2.01 / 3.20 ms; throughput 1,043 requests a second.

Two things to know about these machines

Unless a section says otherwise, a number is from the Lima VM. It is a virtual machine on a laptop with two virtual CPUs, not a server. A real server is usually faster; the numbers here are careful, not flattering.

Only one record is from an x86_64 machine, and it is a shared one. All three hosts above are aarch64 (64-bit ARM). The bench workflow records the same suite on GitHub’s x86_64 and arm64 runners — 4 vCPU of an AMD EPYC 9V45, 16 GB, Linux 6.17, on Azure — and the first record, from 26 September 2026, is in bench/results/. It is a shared virtual machine, and zygo bench all says so in its verdict (“the host was throttled or busy”), so it is a cross-check, not a publication:

x86_64 runner, 26 September 2026measuredthe Lima VM’s number
a warm request, usually1.47 ms1.44 ms
a warm request, 1 in 1001.97 ms10.5 ms
a pooled script, usually2.11 ms1.91 ms
zygo run, usually17.9 ms12.3 ms
throughput at concurrency 4790 requests/s1,108 requests/s

The medians agree with the Lima VM’s within a third; the one-in-a-hundred tail is five times shorter, which is what a 6.17 kernel with favordynmods looks like (below). The arm64 runner, the same day, gave 1.52 / 1.78 ms for the warm path.

Reproduce any of the numbers:

zygo bench warm        # the warm path, with a phase breakdown
zygo bench cold        # a one-shot sandbox, start to finish
zygo bench load        # sustained throughput through one warm function

Reproducing them below lists every flag and budget.

The warm path

A warm function is a sandbox that is already up. A request is a fork() into it. You pay for three things: the fork, the cgroup write that admits the new child, and the reply.

  one warm request, from the supervisor's side
  ─────────────────────────────────────────────────────────────────
   fork ───────► admit ──────► run (GO … DONE) ──► reply
   copy the      put the       your handler        the answer goes
   zygote        child in its  runs; its time is   back to the caller
                 own cgroup    taken out of the
                               overhead
  ─────────────────────────────────────────────────────────────────
Lima VM, 10,000 requests at 250 a second
Usually (median)1.44 ms
1 in 100 (99th percentile)10.5 ms
Sustained throughput1,108 requests a second, 4 clients

These are overhead: the time Zygo adds around your handler, with the handler’s own work taken away. zygo bench warm reports the two separately. It also reports the host’s own fork() floor next to them, which is the time the machine needs for a bare fork. So you can see how much of the number belongs to the machine and how much to Zygo.

What the 1.4 ms is made of

zygo bench warm times each phase of every request. One run of 3,000 requests at 250 a second, on the Lima VM (Linux 6.8) inside a privileged container, on 25 September 2026 — so a little noisier than the table above:

phasewhat happensusually1 in 100
forkthe agent copies the zygote, and says FORKED507 µs4.9 ms
admitthe supervisor makes the request’s cgroup and moves the child in156 µs9.7 ms
runGO to DONE: the child wakes, reseeds, makes its temp folder, runs the handler, writes the answer496 µs4.1 ms
— of which the handleran empty one17 µs85 µs
releasethe request’s cgroup is removed, after the answer has gone26 µs217 µs
the whole request1.19 ms15.6 ms

The same host’s bare fork() and wait() take 95 µs, so most of fork is Python and the protocol, not the kernel. Without a cgroup per request (--no-cgroup), admit drops to 27 µs and the median to 1.0 ms: per-request containment costs about a fifth of the median, and most of the slow tail.

What is not in the number, because it happens once, when the zygote starts (zygo serve: 34 ms on this VM with a supervisor running, about 150 ms on a Raspberry Pi 5 counting the supervisor’s start; warming up has both):

  • creating the namespaces and mounting the root, /proc and /tmp;
  • loading the seccomp filter and the Landlock rules — a child inherits both, and only strict adds a small filter of its own per request;
  • starting the interpreter and running your imports.

Why 1 in 100 is slow on newer kernels

On a stock Linux 6.x, 99 requests in 100 take about 1.5 ms and the last one takes about 10. The phase breakdown shows where: 80–87% of that slow request is admit, the step that puts the new process in its own cgroup. Moving a process between cgroups takes one of the kernel’s locks for writing, and since Linux 6.0 the first writer after a quiet spell waits for the whole kernel to pass a quiet point — several milliseconds. Before 6.0 the kernel kept that lock ready for writers all the time, which is why Docker Desktop’s 5.10 kernel does not show it (1 in 100 there: 2.60 ms).

There are two ways to not pay it, and Zygo uses both:

  • Do not move the process at all. A process created inside its cgroup (clone3 with CLONE_INTO_CGROUP, Linux 5.7) never takes the lock for writing. One-shot sandboxes and zygotes have always been started this way, and since 25 September 2026 so is every warm-exec request.
  • Keep the lock ready for writers. A warm request on the agent path is forked by Python inside the sandbox, where clone3 is refused by the seccomp filter on purpose, so it has to be moved. Mounting the cgroup file system with the favordynmods option makes the move cheap. zygo doctor reports it as cgroup moves, and zygo doctor --fix turns it on, now and at every boot.
Lima VM, Linux 6.8, 10,000 requests at 250 a secondusually1 in 100
agent warm function, as the kernel comes1.61 ms10.3 ms
agent warm function, with favordynmods1.61 ms3.4 ms
pooled script, as the kernel comes2.06 ms11.1 ms
pooled script, with favordynmods1.99 ms3.3 ms
warm-exec, created in its cgroup, as the kernel comes1.43 ms4.3 ms (was 10.5)
  1 in 100 warm requests, Lima VM, Linux 6.8
  ─────────────────────────────────────────────────────────────────
  agent, as the kernel comes     ████████████████████████  10.3 ms
  agent, with favordynmods       ████████                   3.4 ms
  warm-exec, born in its cgroup  ██████████                 4.3 ms
  ─────────────────────────────────────────────────────────────────

What favordynmods costs. It is a setting of the whole machine, not only Zygo’s: every fork and every exit takes a slightly slower path through the same lock. Measured on the same VM, a bare fork-and-wait went from 106 to 108 µs usually and from 206 to 230 µs for 1 in 100. That is why Zygo asks before turning it on rather than doing it for you. Inside a container it is the host’s setting, and doctor says so instead of offering a fix.

Warming up

Warming up is paid once, by zygo serve or the first zygo up. It is the cold sandbox, the interpreter, and whatever the handler imports. After cold_after the sandbox is dropped and the same cost is paid again.

  warm-up of a Python handler, Raspberry Pi 5 (includes starting the supervisor)
  ────────────────────────────────────────────────────────────────
  imports nothing        154 ms  █████████████████████████████████
  imports seven modules  185 ms  ████████████████████████████████████████
  ────────────────────────────────────────────────────────────────
  the seven: json, re, ssl, decimal, datetime, hashlib, urllib.request

Both numbers are the median of five, and both include starting the supervisor, which the first serve does. With a supervisor already running it is less: on the Lima VM, zygo bench warm warms a function in 34 ms. (Before the bytecode layer, the same two took ~270 and ~470 ms: most of the seven imports’ cost was compiling them.)

Warm-exec

In warm-exec, the sandbox is held open but each request is a fresh process, not a fork. This is the mode for a compiled program: no agent, no runtime, just cmd. It costs 1.43 ms usually, and 4.3 ms for 1 in 100. Zygo creates each request directly inside its cgroup, so the kernel tail above does not reach it. Chapter 13 shows how to set it up.

A runtime pool

In a runtime pool the zygote holds no code at all. The script arrives with the request, and the forked child loads it. It costs 1.91 ms usually and 11.4 ms for 1 in 100. That was measured with a different script on every request: a thousand scripts, each called once before anything was measured.

One zygo bench all, Lima VMusually1 in 100
A warm function1.44 ms10.5 ms
A pooled script1.91 ms11.4 ms
What the pool costs+0.47 ms+0.83 ms

Both rows come from one run on one host, so the difference belongs to the pool, not to the machine. Docker Desktop’s VM, the same day, agrees on the cost: +0.46 ms usually, +0.60 ms for 1 in 100.

  warm function vs pooled script, usually, one run, Lima VM
  ────────────────────────────────────────────────────
  warm function   1.44 ms  ██████████████
  pooled script   1.91 ms  ███████████████████
  ────────────────────────────────────────────────────
  one █ is 0.1 ms

That half a millisecond is the whole cost. It is writing the script into the sandbox, and the child compiling and loading it. zygo bench warm --pool --scripts 1000 reproduces it. The memory side — a thousand scripts in one zygote, flat in the script count — is in density with a runtime pool.

When the number is about your limits, not about Zygo

Every function has a cpu limit, its CPU quota. Drive a function past its own quota and the 99th percentile becomes about 47 ms. That is the quota working, not Zygo being slow.

The kernel counts CPU time in periods. A process that has used up its quota waits for the rest of the period before it may run again, and half a period is about 50 ms. A request that lands in that wait pays it.

  cpu quota reached: the tenant waits for the next period
  ──────────────────────────────────────────────────────────────────
  period 1                              period 2
  ████████████████████░░░░░░░░░░░░░░░░░░█████████████████...
  runs, uses its quota  waits (throttled)  runs again
                        ▲
                        a request arriving here waits too: p99 ≈ 47 ms
  ──────────────────────────────────────────────────────────────────

zygo bench warm reads the tenant’s own CPU accounting. When the tenant was throttled, it declines to judge the 99th percentile and prints NOT MEASURED instead. That number would be about the limit, not about the code. A benchmark that cannot tell you which one it measured is not telling you anything.

A one-shot sandbox

zygo run builds a sandbox, runs a program and tears it down.

Lima VM, python3 -c pass, 50 runs
Usually, image already pulled12.3 ms
1 in 10015.3 ms
Budget it was measured against50 ms
The same with /bin/true instead of Python3.6 ms

The /bin/true line is the sandbox alone: namespaces, cgroup, mounts (chapter 6). The rest of the 12.3 ms is Python starting.

Two first-time costs are not in it. The first run of an image also pulls it. The first run on a kernel without unprivileged overlayfs also flattens the image’s layers into one directory. zygo bench cold says which of those happened, because a number that hides them is misleading. (Docker Desktop’s VM is such a kernel; there the same run took 40.7 ms on 25 September — see the machines.)

A one-shot sandbox on a systemd login

On a normal systemd login, zygo run costs more. The shell’s own cgroup cannot hold a sandbox, so zygo run first re-executes itself inside a transient systemd scope (a small cgroup that systemd makes on request). That costs about 12 ms: a scope, a second process, and a cgroup tree that is thrown away.

When a supervisor is running, zygo run hands the sandbox to it instead and pays none of that:

zygo run python:3.12-slim python3 -c pass, usually, median of 15
in its own scope, no supervisor25.7 ms
the same, through a running supervisor13.5 ms
Measured onLima VM, Ubuntu 24.04, kernel 6.8
  zygo run python:3.12-slim python3 -c pass, usually, Lima VM
  ────────────────────────────────────────────────────────────────
  own scope             ████████████████████████████████████████  25.7 ms
  through supervisor    █████████████████████                     13.5 ms
  ────────────────────────────────────────────────────────────────

zygo run -v says which one happened: its timing line ends in (through the supervisor) when it did. The full hunt for this cost is in the other finding.

A sandbox with a hardware boundary

The vm backend boots a guest kernel under KVM (the Linux feature that runs virtual machines) and runs the program inside it.

Raspberry Pi 5, zygo run … true, median of 7
--isolation vm, image already in the store422 ms
--isolation ns, same host, own systemd scope73 ms
  one-shot run, Raspberry Pi 5
  ────────────────────────────────────────────────────────
  ns    ███████                                     73 ms
  vm    ████████████████████████████████████████   422 ms
  ────────────────────────────────────────────────────────

That is about six times the cost of ns, in exchange for a kernel the tenant does not share with the host. The first run of an image is several seconds longer, because the store flattens it. That is all that is measured: the vm backend has no warm path and no networking, so there is nothing else to time yet (ADR 0002 says why).

Dependencies

A requirements file is built into a virtual environment (a venv: a folder with its own Python packages) once. Every run and every function that names the same file against the same image then shares it.

Lima VM, requirements.txt = requests
First build (the plan phase of the first zygo run --requirements)2.5 s
Every later run: finding the built venv1–2 ms
  first build  ████████████████████████████████████████  2516 ms
  reused       ▏                                            2 ms

The build installs with the image’s own pip (pip --python <venv>) and skips ensurepip. ensurepip put a second pip into every venv, and when this was changed it cost 2.0 s of every build before a single package was installed.

The cache is keyed on the image’s digest and the file’s bytes. So two projects with the same requirements share one build, and any edit makes a new one. The same cache serves zygo run --requirements and zygo serve. Chapter 15 shows how to use it.

Python bytecode

Python compiles each .py file to bytecode (a .pyc file) before it runs it, and normally saves that file for next time. The official python:*-slim images ship no .pyc files — 1097 .py files in python:3.12-slim’s standard library and not one compiled. A sandbox’s root is read-only, so Python cannot save what it compiles either. So every run compiled every module it imported again.

So the first zygo pull or run of such an image compiles its standard library once, inside a sandbox, into a layer of its own. On the Lima VM that takes 3.2 s and makes an 18.6 MB layer for python:3.12-slim. The result is served as <image>+bytecode.<key>. The .pyc files sit next to the sources and are unchecked-hash: a layer never changes, so there is nothing to check them against.

Lima VM, the run phase, median of 7without the layerwith it
python3 -c pass9.4 ms6.9 ms
import re, json, hmac, hashlib, base64, datetime, urllib.request, urllib.parse, uuid, decimal164.8 ms34.9 ms
import ssl65.7 ms17.1 ms
  importing ten common modules, Lima VM
  ────────────────────────────────────────────────────────────────
  without bytecode layer   ████████████████████████████████████████  164.8 ms
  with bytecode layer      ████████                                   34.9 ms
  ────────────────────────────────────────────────────────────────

An image that already has bytecode, or has no Python, is served as it is. ZYGO_BYTECODE=0 turns the layer off. A build that fails is a warning, and you get the original image — never a failed run.

On a Mac

Sandboxes are Linux. On macOS every command runs inside a Linux virtual machine that Zygo manages. Crossing into it costs about 22 ms per command once the VM is up. The shim goes over the SSH connection Lima already holds open (ssh -F ~/.lima/zygo/ssh.config). It asks limactl for nothing unless that connection is down — which is when the VM needs booting anyway.

The millisecond warm path is still reachable on a Mac: through the HTTP API or the SDKs. There the round trip happens inside the VM, and the hop is paid once by the connection, not once per request.

Where a one-shot run from a Mac spends its time

Median of nine, after a warm-up, on an M1 Max, 25 September 2026:

supervisor running in the VMno supervisor
zygo run python:3.12-slim true, from a Mac shell28.7 ms41.8 ms
the same, typed inside the VM — the sandbox alone6.2 ms16.4 ms
zygo ps from the Mac — the pure hop, no sandbox23.6 ms
ssh -F … lima-zygo true — the connection alone10.9 ms
zygo --version — no VM at all5.4 ms
  one zygo run from a Mac shell, supervisor running, 28.7 ms end to end
  ──────────────────────────────────────────────────────────────────
  ◄──────── the hop into the VM: ~22 ms ─────────►◄ sandbox 6 ms ►
  ████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
  ──────────────────────────────────────────────────────────────────

Before the shim used the SSH connection directly, the same run was 171 ms. About 148 ms of that was limactl shell (40–50 ms) plus a limactl list per command to ask whether the VM was running. Both are gone from the hot path.

Why run looks slow on a Mac

So “why is run 29 ms from my Mac when bench cold says 12?” has one answer: the hop. A Linux host sees the 12. The same true through Docker Desktop on the same Mac (docker run --rm python:3.12-slim true) took 397 ms. The hop is not being made faster, by decision (ADR 0001 puts macOS latency on its “not now” list). For anything that must be fast on a Mac, use the warm path through the API; chapter 13 has the worked example of a multi-tenant consumer on the warm path.

What is not measured

  • The vm backend beyond the one-shot cost above. There is no warm path to measure on it, and no network.
  • Anything across more than one machine. Zygo’s capacity is a per-host budget, and a 429 answer past it.
  • Receive-side bandwidth shaping (limiting how fast data comes in). It needs an ifb device the test hosts do not have.
  • x86_64 on a machine of the project’s own. The one x86_64 record is from a shared GitHub runner, a cross-check and not a publication, as said above.

Reproducing them

One command runs everything:

zygo bench all          # or `make bench`, which does the container setup too
zygo bench all --quick  # fewer runs: checks the harness works, not a measurement

It runs five steps, prints the machine it ran on, and then compares what it measured with the numbers published in this chapter. It allows a factor of two either way. A difference is not a failure: these numbers were taken on the machines above and yours is a different one. That is why the machine is printed next to the numbers.

  zygo bench all
  ──────────────────────────────────────────────────────────────
   1. warm        a warm function at 250 requests a second
   2. warm-exec   the same, with `sh -c cat` as the command
   3. pool        a runtime pool, a different script each time
   4. cold        a one-shot sandbox, start to finish
   5. load        sustained throughput through one function
  ──────────────────────────────────────────────────────────────
   then: print the host, compare with the published numbers

The single commands and their budgets

Each command has a budget: the number it must beat to print PASS.

CommandWhat it measuresPASS when
zygo bench warma warm functionp50 < 2000 µs and p99 < 10000 µs
zygo bench warm -- CMDwarm-exec, running CMD per requestp50 < 3000 µs
zygo bench warm --pool --scripts 1000a runtime poolp50 and p99 < 5000 µs
zygo bench colda one-shot sandboxp50 < 50 ms
zygo bench loadsustained throughput≥ 600 requests a second

(1000 µs, microseconds, is 1 ms.) If the tenant hit its CPU quota during bench warm, the p99 line reads NOT MEASURED rather than PASS or FAIL.

FlagDefaultWhat it does
warm --n10000how many requests
warm --no-cgroupoffserve without a per-request cgroup, to see what the cgroup costs
warm --rate Ras fast as possibleoffered load in requests a second; unset drives the tenant into its own quota
warm --cpu Cthe spec’s 1.0the tenant’s CPU quota, in cores
warm --pool --scripts N1000measure a runtime pool cycling through N distinct scripts
warm -- CMDnonemeasure warm-exec with this command, e.g. -- sh -c cat
cold --n50how many runs
cold --imagepython:3.12-slimthe image; it must already be pulled
cold --commandstart the interpreter and exitthe program to run
load --seconds10how long to run
load --concurrency4how many clients call at once
load --cpunonethe tenant’s CPU quota, in cores

The raw records

make bench-record runs bench all and the warm path with and without a per-request cgroup, and keeps what they printed as JSON in bench/results/, one folder per run, named after the date, the kernel and the architecture. A run that missed a budget is kept as well. Records from Linux 6.8 and 5.10 on aarch64, and from GitHub’s runners on 6.17, are there now. The bench workflow makes the same record on GitHub’s x86_64 and arm64 runners every week; those are shared VMs, so their numbers are noisier than a quiet host’s, and a run that saw the machine busy says so.

The run behind the short version, as an ordinary user, is not among them: its raw output is not in this repository. The Lima record there is the same VM on the same day, inside a privileged container. It measured a warm request at 1.21 ms usually and 15.4 ms for 1 in 100, and 1,176 requests a second, against the 1.44 ms, 10.5 ms and 1,108 above.

Two things bench all does that most benchmarks do not

It lifts the tenant’s CPU quota for the throughput run, and only for that run. With the spec’s default cpu = 1.0, a tenant hits its quota long before the runtime is the limit, so the number would measure the quota. The latency runs keep the default quota, because there the limit is part of what is being reported.

It refuses to give a verdict on a disturbed host. Around the whole run it reads the CPU’s thermal throttle counters and the Raspberry Pi’s firmware flag. Before it, it reads the load average (how many processes wanted the CPU over the last minute). A number taken on a machine that was overheating or busy is a number about the machine. It exits 2, not 0 or 1, to say which kind of non-zero it is.

Exit codeMeaning
0every budget was met
1a budget was missed
2no verdict: the throttle counters rose, the Pi firmware flagged throttling, or the 1-minute load was above half the number of cores

How these are kept honest

Four rules the benchmarks and test suites are built on. Each one was learned by getting it wrong first.

  • A test attempts the thing, it does not inspect a setting. Reading a flag passes on a kernel that ignores the flag.
  • A test does not disturb what it measures. Checking whether standard output is a terminal, through a pipe, measures the pipe.
  • A latency measurement can say whether it hit a limit. See the CPU quota above.
  • A negative check first proves the thing ran. “The connection was refused” and “nothing happened at all” look the same from outside, and only one of them is a result.

The embedder’s benchmark

The sections above measure Zygo against its own budgets. This one measures it against what an embedder would otherwise do. An embedder is a program, such as a workflow engine, that runs other people’s scripts and would use Zygo inside it. For an embedder, the number that decides is not Zygo’s overhead. It is the ratio to running a container per call.

This is the gate in ADR 0001: the warm fork has to be at least 10× under the best one-shot runner on an import-heavy script, or the product idea is wrong.

make bench-embed            # or: sh tests/linux/bench_embed.sh --runs 100

What the embedder’s benchmark ran

Hostthe Lima VM: 2 vCPU, 4 GiB, Ubuntu 24.04, Linux 6.8, aarch64
Imagepython:3.12-slim, already pulled, the same one for every runner
Scriptsixteen standard-library modules imported at module level, then a little XML, a hash and a UUID
Runs60 per runner, after 3 warm-up calls
Measuredthe whole per-request command: process start, request, answer

A runner here is one way of running the script: a warm fork, a fresh sandbox per call, or a fresh container per call.

The result on the Lima VM

runnerusually (p50)p901 in 100 (p99)fastest
zygo exec (warm fork)2.8 ms3.4 ms11.2 ms2.1 ms
zygo run (a fresh sandbox per call)70.8 ms72.1 ms76.1 ms67.4 ms
docker run --rm542.4 ms553.2 ms559.1 ms528.5 ms
kern boxnot measured on this host — see the Pi table below
  usually, per call, same script, same image, Lima VM
  ────────────────────────────────────────────────────────────────────────
  docker run --rm   ████████████████████████████████████████   542.4 ms
  zygo run          █████▏                                      70.8 ms
  zygo exec         ▏                                            2.8 ms
  ────────────────────────────────────────────────────────────────────────

25× faster than the best one-shot runner. The one-time cost of getting there — warming the function — was 114 ms. Two calls of zygo run would have paid for it.

The ratio was 60× when this was first measured, on Docker Desktop’s VM (6.4 ms against 384.7 ms, with docker run at 761.9 ms). It fell because the one-shot path got five times faster — mostly the bytecode layer, which stopped Python from compiling its standard library on every run — not because the warm path got slower. The gate in ADR 0001 is 10×, so it still holds with room.

A second host, with kern in it

This table is older: it was measured before the bytecode layer existed, and was not repeated on 25 September, to keep load off that machine (it is also a production server). Read its ratios, not its absolute numbers.

The same benchmark on a Raspberry Pi 5: 4× Cortex-A76, 8 GiB, Ubuntu 24.04, kernel 6.5, bare metal, nothing else running. It includes kern 0.10.0, fetched from its releases and checked against the published .sha256. 40 runs each.

runnerp50p90p99min
zygo exec (warm fork)9.2 ms9.5 ms9.8 ms8.6 ms
kern box924.1 ms943.8 ms954.6 ms915.6 ms
zygo run940.1 ms5439.9 ms20582.6 ms923.5 ms
docker run --rm40006.5 ms46684.4 ms51533.1 ms27922.8 ms
  p50 per call, Raspberry Pi 5 (docker left out: see below)
  ────────────────────────────────────────────────────────────────────────
  zygo run          ████████████████████████████████████████   940.1 ms
  kern box          ███████████████████████████████████████    924.1 ms
  zygo exec         ▌                                            9.2 ms
  ────────────────────────────────────────────────────────────────────────

100× faster than the best one-shot runner, which here is kern. Two things in that table are about Zygo and are not flattering. They are the reason the table is printed in full rather than summed up.

On the Pi, zygo run’s slow tail is the SD card

kern and zygo run are the same speed at p50: 924 ms against 940 ms is a tie. But zygo run’s p99 was 20 583 ms. That was chased down, and it is not a code path in Zygo:

  • Per-phase timing was added to zygo run. zygo -v run … prints timing: plan … start … run …, and the same fields land in --outcome. In a slow run the whole stall is in plan: 1324 ms against 7.9 ms for a typical run. start (29 vs 10 ms) and run (46.6 ms, the same) are untouched. plan is everything before the sandbox exists: the image index, the layer and whiteout reads, the root directory’s mkdir. It is the first disk I/O the process does, which is where a stalled filesystem journal is felt.
  • The same 50 runs with the data home on tmpfs (/dev/shm, a filesystem in RAM) instead of the SD card: p99 152 ms, no slow run at all.
  • zygo run itself writes about 2 KB per run to the card (measured from /proc/diskstats across ten runs). It is not causing the pressure; it is stuck behind it.
  • /proc/pressure/io on this Pi read full avg10=75% at the end of the benchmark: every task on the machine was blocked on disk I/O three-quarters of the time. It fell to ~1% within a minute of rest. The root filesystem is an SD card at 89% full that lost 9,699 sectors three days earlier, and /tmp and /var/log/journal are on it.
  zygo run on the Pi: where a slow run's time went, compared with a normal run
  ──────────────────────────────────────────────────────────────────────
             normal run         slow run
  plan         7.9 ms   ▏       1324 ms   ████████████████████████████████
  start         10 ms   ▏         29 ms   ▌
  run         46.6 ms   █       46.6 ms   █
  ──────────────────────────────────────────────────────────────────────
  plan is the first disk read; the card was stalled, so plan waited

So the honest reading is this. On a healthy disk, kern box and zygo run are a tie. On this disk, anything that reads the disk first pays the tail, and zygo run reads first. kern’s flatter p99 here is most likely because its prepared rootfs cache touches the card later or less. That is a real property and a likely one, but not one this host can measure fairly. The warm path’s spread on the same card is 9.2 ms to 9.8 ms, because a fork touches no disk.

On the Pi, the Docker column is not a fair number

It is kept only because deleting it would be worse. Forty seconds for docker run --rm on a Pi is not a believable measurement of Docker. It is a measurement of this Pi’s storage under a cycle of creating and destroying containers, and possibly of the four runners sharing the machine. Do not quote it. The Lima figure above (542.4 ms usually) is the one to read.

What the two hosts agree on

This is the only claim being made. The gap is the interpreter. Every one-shot runner pays the interpreter on every call, including the fastest one. A warm fork does not pay it.

Reading it honestly: the gap is the interpreter

zygo run is 70.8 ms in the Lima table, while the one-shot number for /bin/true is 3.6 ms. Both are right. The ~4 ms is the sandbox; the other ~67 ms is CPython starting and importing sixteen modules — even with the bytecode layer. A one-shot runner that was infinitely fast would still take those ~67 ms on this script, because the interpreter is the cost. Warming it is the only thing that removes it. A fork is the only way to warm it without also keeping its state.

  zygo run, 70.8 ms, Lima VM
  ──────────────────────────────────────────────────────────────────
  ██▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
  █ the sandbox, ~4 ms    ▓ CPython start + sixteen imports, ~67 ms
  ──────────────────────────────────────────────────────────────────

So the ratio is a property of the script, not of the runner. A script that imports nothing would show the three runners much closer together. A script that imports pandas and Pillow would show them further apart. Sixteen standard-library modules is the careful, low end of what a real script does.

2.8 ms is the CLI, not the API

The 2.8 ms includes starting the zygo program itself for every call, and that program talking to the supervisor. An embedder does not pay that. It calls zygo api over a unix socket, or links zygo-core, and gets the ~1.4 ms that zygo bench warm measures. The CLI number is used here because it is the only thing docker run can be compared with.

This host is a small one

The Lima VM is a virtual machine with two CPUs on a laptop. A real server with more cores is faster at everything in the table, and a bare-metal kernel avoids some of the virtual machine’s costs. The ratio between the runners is what carries over; the absolute numbers are this machine’s.

Adding the kern column yourself

# fetch kern from its releases, check it against the published .sha256
ZYGO_BENCH_KERN=/path/to/kern make bench-embed

make bench-embed does not download kern. A benchmark that fetches and runs a binary from the internet is not one to run unattended, so adding the column takes one deliberate act.

The harness calls kern box, not kern run. kern run limits a process on the host with no image and no namespaces. That is a different thing entirely, and comparing it with zygo run would be an honest comparison of nothing.

What was predicted about kern, and what happened

Before it was measured, the prediction was: kern would beat zygo run on the sandbox, and land in the same band as everything else on this script, because it also starts a fresh interpreter per call. That was half right. It lands in the same band (924 ms against 940 ms on the Pi). It does not beat zygo run at p50. Its p99 is far better on that host, and the SD-card section says why that number belongs to the card rather than to either runner.

Density: what script number 501 costs

Density is how many warm scripts fit on one machine. This was the other half of Phase 0 of the embedded-runtime roadmap, and the one that decides whether the old shape could serve an embedder at all. An embedder does not have one function. It has ten thousand scripts, most of them idle. A Zygo zygote was then one function: entry imported, forked per request. So the question is what one more warm script adds.

make bench-density ARGS="--scripts 32"

Thirty-two distinct scripts (a different constant in each source, so nothing can be shared as a duplicate), one image, one runtime, on the Lima VM:

32 warm672.5 MB resident, 366.5 MB proportional
Time to warm each114 ms
One more script, marginal RSS21.01 MB
One more script, marginal PSS11.14 MB

(On Docker Desktop’s VM, when it was the reference, the same benchmark gave 16.39 MB and 9.98 MB.)

Why PSS, and why the slope

PSS is the honest figure. It divides each shared page among the processes that share it, so thirty-two interpreters of one image are not counted thirty-two times. RSS counts every shared page in full for every process, so it overstates.

The cost of one more script is taken from the slope between the first and the last checkpoint, not from the total divided by the count. The first zygote pays for the interpreter’s pages, and the thirty-second does not.

  total PSS as scripts are added (shape, not to scale)
  ─────────────────────────────────────────────────────────
  PSS │                                       ●
      │                              ●
      │                     ●              slope = 11.14 MB
      │            ●                       per extra script
      │   ●  ◄── the first pays for the interpreter
      └──────────────────────────────────────── scripts
  ─────────────────────────────────────────────────────────

Extrapolated: one zygote per script does not scale

This is the number a SaaS company asks first:

scriptsPSS
1 000~10.9 GiB
10 000~109 GiB

This does not work, and that is the finding. Ten thousand scripts is a small platform, and a hundred and nine gigabytes is not one machine. Idle tiering does not rescue it either: a paused zygote is still a process holding its memory. (In this run nothing was tiered down inside the window at all, and the harness says so rather than reporting the warm figure as a paused one.)

Phase 1 of the roadmap (ADR 0001) is the answer: a zygote per runtime instead of per script. The script arrives in the EXEC message and the forked child loads it. Its exit criterion was this number going flat: 1 000 distinct scripts, one runtime, and resident memory that does not grow with the script count.

Density with a runtime pool

Phase 1 is built, so the same benchmark can be asked of it:

make bench-density ARGS="--pool --scripts 1000"

A thousand distinct scripts — the same distinct-constant sources — are each registered once with PUT /scripts, then called by digest, through one runtime pool. Every call goes over the HTTP API. That is the path an embedder uses, and a zygo exec per call would measure process start-up instead.

one zygote per scriptone runtime pool
1 000 scripts, proportional memory~10.9 GiB (extrapolated from the slope)29.4 MB, measured
One more script11.14 MB0.0 kB
Zygotes1 0001
  memory for 1 000 distinct scripts, Lima VM
  ────────────────────────────────────────────────────────────────────────
  one zygote per script   ████████████████████████████████████████  ~10.9 GiB
  one runtime pool        ▏                                          29.4 MB
  ────────────────────────────────────────────────────────────────────────

The slope is not “small”, it is zero. The checkpoint at 125 scripts and the checkpoint at 1 000 read the same 29.4 MB. The pool’s zygote holds an interpreter and a dependency set, and the scripts are never in it. That is the whole of what Phase 1 set out to change, and it is a measurement, not an argument.

The latency half, settled

zygo bench warm --pool --scripts 1000     # or `zygo bench all`, which runs it

A thousand distinct scripts, a different one on every request, each called once before anything is measured, at 250 requests a second. Phase 1’s exit criterion is a 1-in-100 time under 5 ms, with memory flat in the script count. Measured on both machines on the same day:

25 September 2026usually1 in 100
Docker Desktop’s VM (Linux 5.10): a warm function1.55 ms2.60 ms
Docker Desktop’s VM: a pooled script2.01 ms3.20 ms — inside 5 ms
Lima VM (Linux 6.8): a warm function1.44 ms10.5 ms
Lima VM: a pooled script1.91 ms11.4 ms — outside 5 ms

The memory half is met everywhere: the slope is zero. The latency half is met on the older kernel and missed on a stock newer one — and there a warm function, with no pool at all, misses it by the same amount. The slow 1 in 100 is the kernel’s cgroup move (why), not the pool: with favordynmods on the same Lima VM, the pooled script’s 1 in 100 is 3.3 ms, inside the budget. What the pool itself adds is the same on both machines: about half a millisecond usually, and under one millisecond for 1 in 100. It is writing the script into the sandbox and the child compiling it; the phase breakdown puts it in run (GO→DONE), where the load happens, not in fork or admit.

The same thing measured badly, and why it looked like a failure

The first attempt measured the pool over the HTTP API, with a Python client making a thousand calls one after another. On a Raspberry Pi it reported p50 6.28 ms / p99 25.24 ms: outside the budget by a factor of five. What saved the conclusion was the control in the same run — a warm function, no pool involved. It measured 4.28 / 20.99 through the same client on the same host. If the control cannot meet a budget either, the budget is not measuring the thing under test.

The numbers are kept here because the pair is the point:

Raspberry Pi 5, 1 000 scriptsp50p99
Pool, by digest, over the API6.36 ms25.24 ms
Control: a warm function, same API, same host, same 1 000 calls4.28 ms20.99 ms
The pool’s own cost+2.08 ms+4.25 ms

This was measured with the data directory on tmpfs, and repeated with it on the Pi’s SD card: 6.28 / 26.10 against a 4.29 / 21.29 control. The two runs agree to within a millisecond, which rules the storage out. That card has stalled this machine on I/O before, and a tail measured on it is worth nothing until something shows it was not the disk.

What that bad measurement really measured

Neither column is inside the 5 ms budget, including the one with no pool in it at all. So this tool measures a Python client making a thousand serial HTTP calls on a four-core machine. It does not measure the request path the budget was written for. The pool’s own cost is the difference, and even that reads high here (+2.08 ms, against the +0.47 ms the direct measurement finds), because HTTP variance lands in both columns.

It is kept as a warning about tools, and as the number an embedder calling over HTTP from Python will really see on a Raspberry Pi. It is not the number the phase gate is written in. (The same run inside Docker Desktop’s VM: p50 3.38 ms, p99 14.74 ms, slope 0.0 kB. On the Lima VM on 25 September: the pool 2.48 / 5.99 ms over the API, against a warm-function control of 1.54 / 2.38 ms, slope 0.0 kB.)

Memory per warm script on a smaller VM

ADR 0005 ran the density benchmark on the Lima VM earlier, with a hundred distinct Python handlers, each its own function. It agrees with the 25 September run above (11.14 MB proportional per script):

per warm script100 scripts
resident (RSS)21.4 MB2 143 MB
proportional (PSS)11.3 MB1 141 MB
time to warm (zygo serve, round trip)109 ms10.9 s

On that VM about 300 warm Python scripts fit in 4 GB with nothing else running, and a thousand would need 11 GB. The same hundred scripts in one pool used 29.5 MB, and one more script added 0 kB. The ADR has the latency side too.

The other finding: zygo run pays for a cgroup it throws away

This section and the three after it are the story of a fix, with the numbers measured while it was made. Today’s numbers are in a one-shot sandbox on a systemd login.

This is not part of the gate, but it came out of the same work. It is the largest avoidable cost on the one-shot path, and avoiding it took a different route than the obvious one. The whole investigation is written up in crates/zygo-cli/src/scope.rs. Here is the short version.

On an ordinary systemd user session, zygo run re-executes itself inside a transient scope. It has to, because the session-N.scope a login lands in cannot take a child cgroup. Broken down on an idle Ubuntu 24.04 VM, kernel 6.8:

p50
/bin/true0.30 ms
systemd-run --user --scope … -- true5.14 ms
zygo --version3.23 ms
systemd-run --user --scope … -- zygo --version13.72 ms
systemd-run --user --scope … -- zygo run …41.21 ms
zygo run …50.31 ms
  p50, idle Ubuntu 24.04 VM, kernel 6.8
  ──────────────────────────────────────────────────────────────────────────
  /bin/true                        0.30  ▏
  systemd-run --scope -- true      5.14  ████
  zygo --version                   3.23  ███
  scope -- zygo --version         13.72  ███████████
  scope -- zygo run …             41.21  █████████████████████████████████
  zygo run …                      50.31  ████████████████████████████████████████
  ──────────────────────────────────────────────────────────────────────────
  ms; one █ is about 1.25 ms; "scope --" is systemd-run --user --scope --

A fresh cgroup is ~10 ms of overhead before Zygo does anything. The cgroup operations are not where it goes: every mkdir and subtree_control write is under 0.15 ms. Moving a process into a freshly made cgroup is 5.6 ms on its own.

The obvious fix was built, and it cannot work

The obvious fix was to put zygo.slice under user@$UID.service, which systemd already delegates (hands over to the user to manage). That makes the layout last between runs, and the layout does work there. Tenants get memory.max, pids.max and cpu.max, and the limits bite: exit 137 on OOM, threads refused at the pids cap, both checked.

But getting into it is forbidden by cgroup v2’s delegation containment rule. To move a process, you need write access to the common ancestor of the source and the destination cgroups. That ancestor is user-$UID.slice, which is root:root 644 on both hosts checked — an aarch64 VM on 6.8 and a Raspberry Pi 5 on 6.5. The code was removed rather than left as something that never runs.

  moving a process from the login session into zygo.slice
  ─────────────────────────────────────────────────────────────────
  user-$UID.slice          (root:root 644 — you cannot write here)
    ├── session-N.scope    ◄── the zygo run process starts here
    └── user@$UID.service
          └── zygo.slice   ◄── where it wants to go
  the common ancestor is user-$UID.slice, so the move is refused
  ─────────────────────────────────────────────────────────────────

Two more ideas, measured and rejected

systemd-run --slice=zygo.slice still pays for the scope. Caching the zygo doctor host probe made no difference at all: 44.92 ms against 45.33 ms over 90 runs each, taken in turns. The probe cache was kept anyway. The supervisor probes once per function it warms, and an embedder warming five hundred scripts was paying five hundred times.

What works: hand the sandbox to the supervisor

What works is reusing a cgroup that is already delegated and already built — exactly what the supervisor holds. So now zygo run hands a one-shot sandbox to a running supervisor. The client sends the spec and its flags, passes its own three streams (stdin, stdout, stderr) over SCM_RIGHTS (a way to send open files over a unix socket), forwards its terminal’s signals, and waits. The supervisor starts the sandbox in the cgroup it already has. Exit code, stdin, both output streams, --outcome, the deadline and OOM all come back as they would have.

Same VM, same command, forty runs a round, two rounds:

p50p90
zygo run …, making its own scope45.9 / 43.0 ms50.1 / 49.5 ms
zygo run …, a supervisor takes it30.4 / 29.0 ms32.9 / 33.8 ms

A third of the command is gone. None of this ever touched an embedder: a supervisor pays for its cgroup once, at start-up. It was zygo run at a terminal that paid every time — and now it pays only on a machine where nothing else is running.

Verdict of the embedder’s benchmark

Phase 0 of the embedded-runtime roadmap (ADR 0001) passes its gate on every host it was measured on. It is 25× on the Lima VM against zygo run today, was 60× on Docker Desktop’s VM before the bytecode layer made one-shot runs faster, and 100× on a Raspberry Pi against kern, the fastest one-shot runner in the field. The bar was 10×.

  how much faster the warm fork is than the best one-shot runner
  ──────────────────────────────────────────────────────────────────
  the gate                 ████                                       10×
  Lima VM, 25 Sep          ██████████                                 25×
  Docker Desktop, earlier  ████████████████████████                   60×
  Raspberry Pi 5, earlier  ████████████████████████████████████████  100×
  ──────────────────────────────────────────────────────────────────

It also showed why Phase 1 had to come first. The warm fork is worth tens of one-shot runs, yet with one zygote per script you could have only a few hundred warm scripts per host. The thing that makes Zygo worth embedding was the thing it could not yet do at an embedder’s scale. The runtime pool (above) is the fix.

Two defects the benchmark found

Neither of them is in the warm path:

  • zygo run on an ordinary systemd session pays ~34 ms for a cgroup it throws away, and the obvious fix is forbidden by cgroup delegation containment. The supervisor hand-off above now avoids it when a supervisor is running.
  • zygo run had no per-phase timing, so a 20 s p99 on the Pi looked like a Zygo defect for a day. It has one now, and the p99 was the SD card.

Both are in the one-shot path. An embedder does not use that path; a developer at a terminal does.

Behind n8n’s Code node

n8n runs a Code node through a task runner, a process apart from n8n that its task broker hands the code to. examples/n8n-runner is a runner that sends each task to a Zygo runtime pool instead. This section runs the same workflows through three stacks behind the same n8n, and reads every part’s cgroup for CPU and memory.

  n8n 2.38.7 ── broker ──┬─ stock:  n8nio/runners, its own sidecar
                         ├─ Zygo:   zygo_runner.py → a pool fork per task
                         └─ box:    n8nio/runners unchanged, in one Zygo sandbox

Measured on the Lima VM — 2 vCPU, 4 GB, Ubuntu 24.04, Linux 6.8, aarch64, on an M1 Max — on 27 September 2026, Zygo at 970ccba, the kernel as it comes. Every stack ran twice, the second round in the opposite order; the figures are the mean of the two, and the widest gap between rounds was 18 ms. The load came from inside the VM, from a cgroup of its own. Each Code node ran in Python and in JavaScript: trivial returns a count, cpu loops, items transforms 1,000 items, deps hashes with hashlib or crypto. n8n alone, with no Code node, took 23 ms (1 in 100: 55 ms), so every figure below starts there. sh run.sh in bench/ repeats all of it.

n8n: the two languages go opposite ways

One request at a time, the whole workflow, usually · 1 in 100:

workstock, PythonZygo, Pythonstock, JSZygo, JS
trivial213 · 277 ms36 · 65 ms27 · 73 ms49 · 77 ms
cpu230 · 305 ms55 · 73 ms34 · 110 ms55 · 71 ms
items233 · 284 ms74 · 95 ms50 · 117 ms84 · 109 ms
deps214 · 248 ms37 · 63 ms29 · 69 ms45 · 72 ms

200 requests at once from 32 connections, finished per second, and CPU per task for the whole machine’s share of it (n8n included):

workstock, PythonZygo, Pythonstock, JSZygo, JS
trivial, per second7.632.126.523.6
items, per second6.818.120.615.5
trivial, CPU per task254 ms54 ms64 ms78 ms
trivial, CPU on the runner side199 ms4.8 ms7.3 ms28 ms

In Python, Zygo is about six times faster one at a time, four times faster under a burst, and uses a fifth of the CPU. In JavaScript it is slower: 22 ms more per request and 11% fewer requests a second in a burst. None of the 16,800 requests failed.

n8n: why

  • Stock Python pays about 200 ms of CPU per task. Its runner starts a fresh process per task from a fork server and sets up its language-level sandbox there; its own log says 98 ms for the one-line node, and its task executor alone, measured apart, takes 3–7 ms. Where the rest goes was not found. A Zygo fork of a warm interpreter costs 4.8 ms of CPU.
  • Stock JavaScript pays almost nothing per task. Every task runs in one Node process, in a vm context of its own: no process is made.
  • Zygo’s JavaScript pays for a process per task. Node cannot be forked, so the agent starts a fresh Node worker for each request (chapter 13): about 25 ms of CPU, which on two cores is what limits the burst.
  • n8n itself costs about 50 ms of CPU per task in every stack. On two cores it is the ceiling Zygo’s Python meets.
  • The box, n8n’s runners inside one Zygo sandbox, is 35% faster than the stock sidecar in Python and level in JavaScript. Modules load about 20% faster from Zygo’s root than from Docker’s overlay2 (44 ms against 55 ms for the runner’s own); the rest of the Python gap was not traced.

n8n: the first run, memory, and hostile code

The first run after the runner side restarts, and after 30 s idle, trivial JavaScript · Python, usually:

stockZygobox
after a restart986 · 506 ms61 · 45 ms1,007 · 353 ms
after 30 s idle975 · 496 ms56 · 38 ms1,011 · 352 ms

n8n’s launcher stops a runner that has been idle for 15 s, so a workflow run now and then pays the second row on nearly every run. The same launcher inside the box does the same. It is also why the stock runner side holds 2 MB at idle, against 74 MB for Zygo’s two warm pools, API and runner; under a Python burst, the other way round: 222 MB against 85 MB.

Six hostile Code nodes, once each (probes.sh):

stock, as shippedstock, modules allowedZygobox
network: n8n, broker, LAN, internetblocked (module refused)all reachedblockedblocked (module refused)
2 GB allocatedno limit: succeeds, or the kernel kills the whole runnersucceededthe task dies alonethe whole box dies
… with three tasks beside itJS: all three died with the runnerall three finishedall three finishedall three died
1 GB written to /tmprefused (fs, open)Python: open still refusedstopped at 64 MBrefused (fs, open)
JavaScript loop foreverother JS tasks wait 55–60 ssamenothing else waitssame as stock

“Modules allowed” is n8n’s own runner with NODE_FUNCTION_ALLOW_BUILTIN=* and N8N_RUNNERS_STDLIB_ALLOW=*, which is how most users get a library into a Code node. The box ran n8n’s runner config as shipped; with modules allowed, an earlier round’s box reached the broker and nothing else. Whether stock’s 2 GB succeeds depends on what the machine has free: in an earlier round it did; in this one the kernel killed the JS runner and every task in it.

n8n: what these numbers are not

  • Not a server. Two virtual CPUs are shared by n8n, its database writes and the runner, and a vCPU that sleeps between requests takes time to wake: a pool call spaced 0.1 s apart took 11 ms where back-to-back ones took 3 ms. Zygo’s path has more hand-offs between processes, so it pays that more often. x86_64 and bare metal were not measured.
  • Not the whole Code node. The Zygo runner lacks this.helpers (HTTP and binary data), $node, $workflow, $env and static data; see the example’s README.
  • One n8n version. n8n 2.38.7 and n8nio/runners:2.38.7, Node 26.7 and Python 3.13.15 in both; the numbers age with each n8n release.

26. Why it is built this way

Some choices in Zygo look strange until you know the reason: why the vm backend has no warm functions, why there is no Deno agent, why an upgrade throws the warm sandboxes away. Each of these was written down as an Architecture Decision Record (ADR): a short, dated note of a question, the answer, and what would change the answer. This chapter explains the nine ADRs in plain words; each section links to the full record in adr/.

The nine decisions at a glance

ADRThe questionThe answer, in one line
0001Who is Zygo for?Programs that embed it to run many scripts — not a person typing commands.
0002Should vm and gvisor have warm functions?No. Warm functions are an ns feature.
0003Should there be Deno and Bun agents?No, until an embedder asks with a measurement.
0004Can an upgrade keep the warm sandboxes?No. An upgrade drains, restarts and re-warms.
0005One warm zygote per script version? What evicts it?Yes for heavy scripts, a runtime pool for light ones; timeouts evict.
0006What does mem bound in a warm function?One request, on its own cgroup — never the function as a whole.
0007Where does an Elixir client live, and what does it need?Here, as sdk/elixir; Mint and NimblePool; one error with a kind.
0008May a sandbox listen on its own loopback?A function may; a runtime pool, shared between tenants, may not.
0009May what the API serves reach a private address?Only when zygo api --allow-private-net says so; never a request body.
  how the nine decisions depend on each other
  ─────────────────────────────────────────────────────────────────
   0001  the product is the warm path, for an embedder
     │
     ├──► 0002  keep one warm path (ns), do not build three
     ├──► 0003  keep the agent list small enough to test fully
     ├──► 0004  restarts re-warm; keep one copy of the state
     ├──► 0005  how a real embedder should use warm paths
     ├──► 0006  one tenant's request cannot take the others down
     ├──► 0007  an embedder reaches it through an SDK in its own language
     ├──► 0008  a function may listen; a pool shared by tenants may not
     └──► 0009  the operator, never a request, opens private addresses
  ─────────────────────────────────────────────────────────────────

Each ADR ends with the facts that would reopen it. None of them is “forever”. They say “not until this is true”.

How to read an ADR

An ADR is a historical record. It is written once, at the time of the decision, and not rewritten later. If a decision changes, a new ADR replaces the old one. So an ADR can mention files or plans that have since moved. The sections below are today’s plain-English summary; the ADR itself is the exact wording.

ADR 0001: Zygo is the embedded script runtime

The question

Zygo can be described in two ways. One is “a faster Docker”: one static binary, no daemon, no root, OCI images, a sandbox in about 12 ms instead of 300–1000 ms. That is true, but the field is crowded — kern, nono, microsandbox and Docker’s own sandboxes all compete there. The other is “the runtime a workflow engine embeds”: a program with ten thousand scripts in a database, each run a few times a minute. The question was which of the two is the product.

The decision

The product is the second one. The target user is an embedder: a workflow engine (Windmill, n8n, Temporal, Kestra), a SaaS running plugins its customers wrote, or an agent platform running generated code. Four things follow from that, in order of how often they come up:

  1. The warm path is the product. A change that costs the warm path milliseconds needs a much better reason than one that costs the one-shot path the same.
  2. The API is the surface, not the CLI. The CLI stays for debugging and demos.
  3. Tenants are strangers to each other, so isolation between tenants gets the effort.
  4. One process per worker, one host. No scheduler, no control plane, no Helm chart — the embedder has those already.

Why

Nobody else serves the “ten thousand scripts, each run often” shape. A warm fork is the tool built for it: each request is a fork() of a process that never served a request, so it is as clean as a fresh container and as cheap as a fork. That is the one part another project would have to rebuild rather than just make faster. The one-shot sandbox still matters, but mostly because the warm sandbox is built out of it.

What it costs

Some things are put aside on purpose, so nobody reopens them by accident: services, ports, compose files and restart policies; warm functions on vm and gvisor (see ADR 0002); the macOS shim’s latency; Windows; and a hosted service, which would compete with the very users Zygo is for. Work that is good for a developer at a terminal but bad for an embedder now loses. The macOS hop is the standing example: a developer notices it, a production embedder never sees it.

The gates, and how they were met

The ADR sets a gate for each phase of the roadmap, so that a phase cannot be declared done by whoever did the work.

  phase                        done when …                                    status
  ─────────────────────────────────────────────────────────────────────────────────
  0 prove the wedge            warm fork ≥ 10× under the best one-shot runner  met
  1 runtime zygotes            1 000 scripts, < 5 ms, memory flat              met
  2 embedder API               a plugin host needs only the HTTP API
  3 runtimes                   Python and JavaScript through one API
  4 deployability              kubectl apply → green probe + agent test passes
  5 hardening                  no "untested" rows for multi-tenant claims
  6 integrations               one outside project runs it in production
  ─────────────────────────────────────────────────────────────────────────────────

Phase 0 was a real gate: without the 10× ratio, the ADR would be wrong, not early. It passed at 25× on the Lima VM — and at 60× on Docker Desktop’s VM before the bytecode layer, and 100× on a Raspberry Pi in an older run that chapter 25 has not repeated. Phase 1 passed on the kernel it was set on: on Docker Desktop’s Linux 5.10 VM even the slowest 1 in 100 calls took 3.2 ms, and a thousand scripts in one zygote used 29.4 MB. On the Lima VM’s Linux 6.8 the same 1 in 100 is 11.4 ms as the kernel comes, and 3.3 ms once zygo doctor --fix has turned on favordynmods; a warm function with no pool at all shows the same tail, so it is the kernel’s cgroup move, not the pool. Chapter 25 explains the tail and has both measurements.

What would reopen it

The ADR names no single trigger; it stands as long as its Phase 0 gate holds. If the warm fork stopped being many times cheaper than the best one-shot runner on an import-heavy script, the idea behind the product would be wrong. Full ADR 0001.

ADR 0002: Warm functions stay on ns

The question

Zygo has three isolation backends behind one flag: ns (namespaces, cgroups, seccomp and Landlock), gvisor (a kernel written in user space) and vm (libkrun, a real hardware boundary). All three run one-shot sandboxes from the same spec. Only ns runs warm functions. The question was whether the other two should get warm functions too, or whether the gap was just unfinished work.

The decision

Warm functions are an ns feature. vm and gvisor run one-shot sandboxes and refuse warm modes with a reason. That refusal is the design, not a gap. Networking on vm is refused for the same reason. The error messages point at this ADR instead of saying “not yet”.

Why

Both gaps come from how the backends are built, not from missing time:

  • The agent gets its control socket as an inherited file descriptor. An OCI runtime such as runsc closes everything except stdin, stdout and stderr, and a guest VM inherits nothing from the host at all. gvisor would need runsc exec instead of setns; vm would need the protocol carried over vsock and a supervisor inside the guest.
  • Networking on vm would need the VM monitor inside Zygo’s own network namespace, and the allowlist applied to a guest interface. That is a second copy of the network code with none of the first copy’s tests.

Each is weeks of work. Worse, each makes a second warm path that must be kept correct, measured and defended — next to the one the whole product rests on.

                      one-shot     warm function    network
  ─────────────────────────────────────────────────────────────
  ns                  yes          yes              yes
  gvisor              yes          refused          —
  vm                  yes          refused          refused
  ─────────────────────────────────────────────────────────────

What it costs

--isolation vm is a hardware boundary for work that fits a one-shot sandbox: an untrusted build, a single tool call, a job with an input and an output. It is not for a warm function serving many requests. An embedder who needs a hardware boundary per tenant is not served by Zygo today; chapter 10 points at an alternative. The effort goes instead into hardening ns, which stays one kernel away from the host (chapter 23).

What would reopen it

An embedder who asks for a hardware boundary per tenant, with a workload that can afford about 100 ms of boot per request. That is a different product shape from the warm fork. It should be thought through as its own thing, not bolted onto this backend just because the flag already exists. Full ADR 0002.

ADR 0003: No Deno or Bun agent until an embedder asks

The question

Zygo ships two runtime agents, Python and Node. A third-party agent can be added with agent = { agent = "/path/in/sandbox" }. Deno and Bun are the obvious next two: both are popular, both start fast, and both would look good in a table. The question was whether to write agents for them.

The decision

No Deno or Bun agent. Anyone who wants either has two supported paths. One is a warm-exec pool, which works today with no agent at all, because both start in a few milliseconds:

[runtime.deno]
image = "denoland/deno:alpine"
cmd   = ["deno", "run", "--allow-none"]

The other is to write their own agent against the protocol and check it with zygo agent test (chapter 18).

Why

An agent is cheap to write and expensive to keep. Each one is a fork boundary with four rules that must be exactly right: the child never returns to the parent’s loop, nothing runs before GO, a broken frame is reported and not fatal, and every EXEC gets exactly one answer. Each rule has been broken at least once in this repository, by someone who knew the language well. Each agent also needs its own answer to the per-request seccomp filter; Node’s fallback was once stricter than the filter it replaced, which the seccomp matrix caught. And each agent must sit in make conformance, the seccomp matrix and the image matrix — a test nobody runs is only a claim.

Meanwhile Deno and Bun both run JavaScript, which the Node agent already serves. Someone who asks for Deno usually wants its permission model, its standard library or deno.json, not “something other than V8”.

What it costs

The comparison table says two agents, not four — the honest number. The test matrices stay small enough to run on every change. A Deno user starts with a three-line cmd and no protocol. Each request is slower than a fork from a warm heap by the cost of an execve, not by the cost of an interpreter start-up. The warm-exec pool gives up streaming, progress(), workspaces and per-request tenant limits.

What would reopen it

Any one of three facts — none of them a guess about the future:

  • An embedder asks, with a workload where the warm-exec pool’s per-request execve is measurably too slow. The measurement is the argument, not the runtime’s popularity.
  • A dependency set needs it: supporting deno.json or bun.lockb in POST /deps, which is smaller work than an agent.
  • Someone writes an agent and it passes zygo agent test, including the child-filter checks. The project would rather link to it than rewrite it.

Full ADR 0003.

ADR 0004: A supervisor upgrade re-warms; there is no --reexec

The question

Upgrading Zygo replaces the supervisor process, and its warm sandboxes go with it. Could the new binary exec over the old one and keep them, so an upgrade costs nothing? exec is the right tool to ask about: it keeps the process id, so the sandboxes’ init processes stay children of the same process, and nothing dies just because the binary changed.

The decision

No zygo api --reexec. A supervisor upgrade is a restart: drain, exit, start, re-warm. min_warm and a rolling update with maxUnavailable: 0 make it invisible to callers, and both already exist (chapter 16).

  an upgrade, with two replicas and maxUnavailable: 0
  ──────────────────────────────────────────────────────────────────────
  old supervisor   serving ████████████ POST /drain ▓▓▓ finish ▓▓ exit
  new supervisor              start ░░ re-warm ░░ ready ████████ serving
                                        ~150–185 ms per Python zygote
  callers see:     no dropped request; at worst, slower ones while warm-up runs
  ──────────────────────────────────────────────────────────────────────

Why

What would have to cross the exec is much more than process ids:

  • Every file descriptor, on purpose. Each warm function holds an agent socket; each sandbox holds seven namespace descriptors, a /run/secrets descriptor and a cgroup descriptor. All are close-on-exec (CLOEXEC) by design — once after a real bug, where thirteen descriptors leaked into every tenant program. A hand-over turns “everything closes unless something says otherwise” into “everything closes unless this list says otherwise”, and the list changes per function, per sandbox and per release.
  • All the state that is not a descriptor: every function’s spec, counters, logs, secrets, script leases, queue counts and idle clocks. That means a second, versioned copy of the whole supervisor state, which could go wrong silently.
  • The requests in flight. Their answers are owed to client connections tracked by a thread that exec destroys. Keeping them means handing over the client sockets, each request’s cgroup, child and deadline, and rebuilding the router. Anything less drops requests — the one thing the feature was for.

What it costs

A Python zygote re-warms in about 150–185 ms on a Raspberry Pi 5, the supervisor’s start included, and in 34 ms on the Lima VM with a supervisor already running (chapter 25). When this was decided, before the bytecode layer, it was about 500 ms (502 ms for a pool, 508 ms for a function, from tests/linux/api_driver.py). A Node zygote is ready in 15–43 ms. So an upgrade costs min_warm × warm-up of cold pool per replica, once. A single-replica deployment has a short window where requests are slow, not failed; two replicas remove it, and the Kubernetes example uses two. In return, descriptors stay CLOEXEC with no exceptions, and Zygo keeps only one copy of its state: the running one.

What would reopen it

  • A warm-up of tens of seconds, not a fraction of one. Even then, the better fix may be to make that warm-up faster.
  • A deployment that cannot have two replicas, with a latency budget a cold start breaks. --reexec is one answer; a second supervisor on the same host behind a load balancer is another, and needs nothing new.

What does not reopen it: wanting upgrades to be free. They are already free of dropped requests, which is the part that matters. Full ADR 0004.

ADR 0005: One warm zygote per script version, and what evicts it

The question

The shape that forced the question is a multi-tenant application: tenant code that changes whenever someone presses Save, hundreds of projects, each with its own mounts, network allowlist and per-run secrets. Such a product starts with zygo run, a sandbox per event, and asks three things. Is one warm zygote per script version the intended shape? What evicts warm zygotes when a server has four hundred projects? And can secrets and mounts vary under one warm zygote?

What was measured

A hundred distinct Python handlers on the Lima VM (Ubuntu 24.04, kernel 6.8, aarch64, 2 vCPU, 3.8 GiB), python:3.12-slim:

per warm script100 scripts
memory (RSS)21.4 MB2 143 MB
memory, shared pages split fairly (PSS)11.3 MB1 141 MB
time to warm109 ms10.9 s
The same hundred scripts in one runtime pool
memory, whatever the count29.5 MB, one zygote
what one more script adds0 kB
second call over the API: usually / 1 in 1001.95 ms / 2.38 ms
a warmed function on the same host: usually / 1 in 1001.36 ms / 1.57 ms
the pool’s extra cost+0.5 ms usually, +0.8 ms for the slowest 1 in 100

So about 300 warm Python scripts fit in 4 GB on that VM, and a thousand would need 11 GB. Chapter 25 explains RSS and PSS.

The decision, part 1: which shape

One warm zygote per script version is right when the script’s imports are worth paying once — an ML model, a large client library. It pays those once and about 1.4 ms per call, and costs 11 MB while warm. A runtime pool is right when the script is a few lines over the standard library, like most such scripts. It pays about 0.5 ms more per call and nothing to stay resident, because the script is not kept; it arrives with each request. (Chapter 25 puts the pool’s cost at +0.47 ms on the Lima VM and +0.46 ms on Docker Desktop’s; the ADR quotes 0.6 ms, from its own earlier run.)

  does the script import something expensive?
  ─────────────────────────────────────────────────────────────
     yes ──► one warm zygote per script version
             pays the imports once · ~1.4 ms a call · ~11 MB warm
     no  ──► a runtime pool
             +0.5 ms a call · 0 kB per extra script
  ─────────────────────────────────────────────────────────────

The decision, part 2: what evicts

Eviction is idle_timeout and cold_after, per function, and nothing else in the product. A version nobody called for idle_timeout (default ten minutes) is paused: frozen, still in memory, one write to wake. Past cold_after (default an hour) it is cold: the sandbox is dropped, only the spec is kept, and the next call pays the 109 ms plus imports. An LRU list of warm scripts is the consumer’s own policy on top, choosing which versions to serve at all. max_warm is a different knob: a pool’s ceiling on its own zygotes under load. When the host is full, the answer is a 429, not a swap storm.

  a script version's life
  ─────────────────────────────────────────────────────────────────────
  warm ──(no call for idle_timeout, 10 min)──► paused ──(cold_after, 1 h)──► cold
   ▲                                             │                          │
   └──────────── one write to wake ◄─────────────┘                          │
   └──────────── 109 ms + imports to warm again ◄───────────────────────────┘
  ─────────────────────────────────────────────────────────────────────

The decision, part 3: secrets and mounts

Per-run secrets can vary under one zygote. Their names are declared on the function; their values arrive with the request and exist as /run/secrets/<name> only while it runs, never in the zygote. Mounts and the network allowlist cannot. They are the sandbox’s namespaces, built once at warm-up. So one script version used by two projects with different mounts is two functions, named {project}-{digest} — which matches the adopter’s own model, where a script belongs to a project.

What it costs, and what would reopen it

A busy consumer keeps as many warm zygotes as were called in the last idle_timeout, and zygo ps shows how many. The density benchmark must be run again when the interpreter, the image or the kernel changes. Left open for a later ADR: a global warm budget (max_warm_total) that evicts the least recently called function when the host nears its memory limit. Nothing measured says it is needed before idle_timeout and a 429 do their job; a consumer who shows that need would reopen it. Full ADR 0005.

ADR 0006: The memory limit is each request’s, not the function’s

The question

A warm function or a runtime pool runs several requests at once in one sandbox: a zygote, and one process per request under it. Its mem limit was written on the function’s cgroup, the group that holds the zygote and every request together. So mem was one shared budget, and the kernel’s “kill the whole group” setting sat at that level too. In a pool at mem = 256M, one request that asked for 2 GB was killed — and so were the zygote and the requests sleeping beside it, in the same moment. In a runtime pool those other requests can belong to other tenants. The book promised the opposite: that a request has its own group and can be killed alone. The question was where mem should really be written.

The decision

mem goes on the leaves — the smallest groups at the bottom of the tree — and nowhere above them. Each request’s own cgroup gets mem, with the group kill turned on there. The zygote’s leaf gets the same limit, so the warm process is bounded on its own. The function’s cgroup keeps the limits that really are one budget for the whole function: processes, CPU and swap. Its memory limit is set to “none” on purpose, so a folder left over from an older Zygo does not keep the old shared limit.

  BEFORE                                  AFTER
  ┌─ function: mem, group kill ──────┐    ┌─ function: pids, cpu, swap ──────┐
  │ ┌────────┐ ┌─────────┐ ┌───────┐ │    │ ┌────────┐ ┌─────────┐ ┌───────┐ │
  │ │ zygote │ │ req (a) │ │ req b │ │    │ │ zygote │ │ req (a) │ │ req b │ │
  │ └────────┘ └─────────┘ └───────┘ │    │ │  mem   │ │   mem   │ │  mem  │ │
  └──────────────────────────────────┘    │ └────────┘ └─────────┘ └───────┘ │
  (a) goes over: all three die            └──────────────────────────────────┘
                                          (a) goes over: only (a) dies

What it costs

A tenant’s narrower limits still go on the request’s cgroup, in place of the function’s numbers. A whole function may now use up to (concurrency + 1) × mem in each sandbox, not mem, so a host sized as “functions × mem” was sized for the old rule; chapters 13 and 20 say so. Memory a request shares with the zygote from the fork stays counted on the zygote, so only what a request allocates after it starts counts against its own mem. No extra ceiling was added above the leaves: it could only fire on memory the zygote holds, and a kill there would again reach the wrong process. make verify-oom-linux checks the promise, for the Python and the Node agent: a request that goes over dies, and the requests beside it finish.

What would reopen it

  • A kernel that moves a process’s memory charge with it when it changes cgroup. Then a ceiling above the leaves would be exact, and worth adding.
  • An embedder who wants mem to mean the whole function’s budget again, with a measurement of what the per-request shape costs them.

Full ADR 0006.

ADR 0007: A third SDK, in Elixir, in this repository

The question

The first product built on Zygo from Elixir ran each script by starting the zygo binary, and about a thousand lines of it worked around that: stdin through a shell, output order lost on macOS, a warm-function table behind a global lock, errors guessed from their text. The HTTP API already answers all of it. The question was where a client should live, what it should depend on, and how it should report failure.

The decision

It lives in this repository as sdk/elixir, and is zygo_sdk on Hex. The Python and Node clients already have a contract here — a stand-in API, the method table in chapter 17, a test per OpenAPI operation, one version for everything — and a client elsewhere would have to follow it by hand.

It depends on Mint, which is an HTTP connection as a plain value and opens a unix socket, and NimblePool, which lends one of those values to one process at a time. Erlang’s own HTTP client can reach a unix socket, but it keeps its connections to itself, so the rules the other clients test — drop a connection idle for 20 seconds, send again only when nothing came back — could not be kept.

Failures are one exception, Zygo.Error, with a kind such as :busy or :handler. Python needs eleven classes because it branches with except; Elixir branches on data, with case.

  Python:  except zygo.Busy as e:            Elixir:  {:error, %Zygo.Error{kind: :busy}}
           except zygo.HandlerError as e:             {:error, %Zygo.Error{kind: :handler}}

What it costs, and what would reopen it

The Elixir client has two dependencies where the others have none, and Mint brings a third small one. make test-sdk runs three suites; without Elixir the third says it skipped. Publishing needs a Hex key. An :httpc that let a caller hold its own connections would let the client drop Mint. Full ADR 0007.

ADR 0008: A function may listen on its own loopback; a pool may not

The question

From Linux 6.7 Zygo refused every TCP bind in every sandbox, and under network = "none" every connect too, on the reasoning that no mode has ingress so nothing should listen. n8n’s runner launcher could not start in a sandbox: each runner listens on a local health-check port. Neither could anything else that talks to itself over 127.0.0.1 — Jupyter, Ray, PyTorch’s distributed runtime, a headless Chrome. And only on 6.7+; on Debian 12’s 6.1 the same spec worked. The question was what the rule was protecting.

The decision

Ingress is pasta’s job, done on every kernel: it forwards no port into a sandbox, and under none there is no interface but loopback. The bind rule protected one thing — a runtime pool, whose requests belong to different tenants and share a namespace, where a loopback listener is a channel between them. So the rule stays exactly there, and goes everywhere else: a function or a zygo run sandbox may listen on its own loopback, and a sealed function is bounded by its empty namespace rather than by a rule.

  function: one tenant's namespace         pool: shared between tenants
  bind ✓  connect to self ✓                bind ✗ (strict, then Landlock)
  from outside: nothing gets in            from outside: nothing gets in

What it costs, and what would reopen it

Under egress the connect half of talking to yourself still meets the allowlist’s port rule, so a function names the port it listens on. Two tenants’ requests in one namespace outside a pool would need the flag set, not the rule revisited. Landlock gaining an address scope for bind would let pools listen safely too. Full ADR 0008.

ADR 0009: The API may allow private addresses when its operator says so

The question

An embedder’s pool scripts had to call back to the embedder’s own service, on an address in a private range. Chapter 14’s answer for that is an allow rule naming the address and --allow-private-net typed by a person. A spec file served by hand could have it, but nothing served over the API could: the API always sent allow_private_net: false, so that no request body could widen the boundary.

The decision

zygo api --allow-private-net sets it for what deploy callers serve. A body still cannot. The person starting the API types it, as they type --allow-deploy, and it does nothing without that. A rule still names one address and one port. GET /version says private_net.

Two things found on the way are refused now, where they used to be accepted and then fail on every request: a private address written without a prefix, and a network under seccomp = "strict", which has no socket.

What it costs, and what would reopen it

Every deploy caller of such a listener may allow private addresses, not only the one that needed it. Deploy rights are already a shell as the API’s user, so this adds little. A channel for asking the caller mid-run, with no network at all, would reopen it, and so would deploy rights handed to parties the operator does not trust. Full ADR 0009.

ADR 0001 — Zygo is the embedded script runtime

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-21. This is the decision the roadmap is the plan for; the roadmap says what and when, and this says who for and what that costs.

Context

Zygo can be described two ways, and only one of them is a product.

“A faster Docker.” One static binary, no daemon, no root, OCI images, a sandbox in 18 ms instead of 300–1000. This is true, it is what the README led with, and it is a crowded field: kern does the same thing and starts a box in single-digit milliseconds, nono confines a process without any of the machinery, microsandbox gives a hardware boundary per call, and Docker itself now ships sandboxes. Zygo is not the fastest of these and has no reason to become it.

“The runtime a workflow engine embeds.” A process that has ten thousand scripts in a database, runs each a few times a minute, and can afford neither a container nor a cold interpreter per run. Nobody is serving that shape. The warm fork — a request costs a fork() of a process that has never served one, so it is as clean as a fresh container and as cheap as a fork — is the only primitive built for it, and it is the only thing here that another project would have to rebuild rather than out-optimise.

The second is the product. Everything about the first is a means to it: the one-shot sandbox exists because the warm one is built out of it.

Decision

The target user is an embedder: a workflow engine (Windmill, n8n, Temporal, Kestra), a SaaS running customer-written plugins, an agent platform running generated code. Not a developer running a command.

What that decides, in order of how often it comes up:

  1. The warm path is the product. The README leads with it, the benchmarks compare against what an embedder would otherwise do rather than against a budget, and a change that costs the warm path milliseconds needs a reason that a change costing the one-shot path milliseconds does not.
  2. The API is the surface, not the CLI. An embedder’s worker talks to zygo api or links zygo-core. The CLI stays, because it is how the thing is debugged and demonstrated, but it stops being what the design is shaped around.
  3. Multi-tenancy is a first-class concern, because an embedder’s customers are not each other’s. Tenant-versus-tenant isolation gets the effort that a per-request hardware boundary does not.
  4. One process per worker, one host. No scheduler, no control plane, no Helm chart. The embedder already has all three.

What is deprioritised, and why

Written down so nobody re-opens them by accident. This is the same list as the roadmap’s, with the reasoning kept here.

  • Services, ports, compose, restart policies. A different product. kern and Docker both do it; Zygo runs functions, not servers.
  • Warm functions on vm and gvisor. A second warm path to keep correct, benchmark and defend, against the one everything rests on. See ADR 0002.
  • macOS shim latency. A Mac is where Zygo is developed and tested; Linux is where it runs. The hop costs about 22 ms per command, which a developer notices and a production embedder never sees. Keep it working.
  • Windows. Embedders deploy on Linux.
  • A hosted service. It would compete with the people this is for.

Exit criteria

Each phase of the roadmap has one; they are the same criteria, here, so that a phase cannot be declared done by whoever is doing it.

PhaseDone when
0 Prove the wedgethe warm fork’s p50 is at least 10× under the best one-shot runner on an import-heavy script, measured on one host, published with the commands
1 Runtime zygotes1 000 distinct scripts on one runtime, p99 under 5 ms after each script’s first call, resident memory flat in the number of scripts
Met. p99 2.92 ms with a different script on every request (zygo bench warm --pool --scripts 1000), and 29.4 MB in one zygote for a thousand scripts — a slope of 0.0 kB per script, against 9.98 MB for a zygote each. Both in bench-embed.md, both reproducible by make bench and make bench-density ARGS="--pool --scripts 1000". Re-measured 25 September 2026: 3.20 ms on Docker Desktop’s Linux 5.10 VM, 11.4 ms on the Lima VM’s stock Linux 6.8 and 3.3 ms there with favordynmods; chapter 25 explains why the tail depends on the kernel, not the pool.
2 Embedder APIa plugin host can be written against the HTTP API alone — no sandbox.toml, no files on the Zygo host
3 Runtimesthe same plugin host runs a Python and a JavaScript plugin through one API with one set of limits
4 Deployabilitykubectl apply of the example on a stock cluster gives a green readiness probe and a passing zygo agent test inside the pod
5 Hardeningthe threat model has no “untested” rows for any multi-tenant claim
6 Integrationsone external project runs Zygo in production for user scripts, publicly

Phase 0 is a real gate. If the ratio is not there, the wedge is not there, and this ADR is wrong rather than early.

Consequences

  • The README, the comparison doc and the benchmarks were all written for the first framing and have been rewritten for the second. That work is done.
  • docs/bench-embed.md is the number this decision rests on, and it has to be reproducible by a reader on their own host — hence make bench-embed.
  • The current zygote-per-function shape does not serve ten thousand scripts. make bench-density is how much it does not, and Phase 1 is the answer.
  • Work that is good for a developer at a terminal and bad for an embedder now loses. The macOS hop is the standing example.

ADR 0002 — Warm functions stay on ns; vm and gvisor stay one-shot

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-21. Supersedes the “not built yet” wording in docs/comparison.md, the backends’ own refusal messages and the ## Status section of the README.

Context

Zygo has three isolation backends behind one flag: ns (namespaces, cgroups, seccomp, Landlock), gvisor (a userspace kernel), and vm (libkrun, a hardware boundary). All three run one-shot sandboxes from the same spec. Only ns runs warm functions.

Both gaps are structural rather than unfinished:

  • The agent is handed its control socket as an inherited descriptor. An OCI runtime closes everything but stdio, and a guest inherits nothing from the host at all. Warm functions on gvisor would need runsc exec in place of setns and a different way to reach the agent; on vm they would need the protocol carried over vsock and a supervisor inside the guest.
  • Networking on vm would need the VMM inside Zygo’s own network namespace and the nftables allowlist applied to a guest interface, which is a second implementation of net/ with none of the first one’s tests.

Each is weeks of work, and each would make a second warm path to keep correct, to benchmark and to defend — against the one that the entire product rests on.

Decision

Warm functions are an ns feature. vm and gvisor run one-shot sandboxes and refuse warm modes with a reason, and that refusal is the design, not a gap. vm networking is refused for the same reason.

This is the decision the roadmap records under “Not planned”, and it is written here so that the backends’ error messages can point at something rather than saying “yet”.

Consequences

  • --isolation vm is a hardware boundary for work that fits a one-shot sandbox: an untrusted build, a single tool call, a job with an input and an output. Not for a warm function serving many requests.
  • An embedder who needs a hardware boundary per tenant rather than per request is not served by Zygo today, and should read docs/comparison.md.
  • The threat model’s statement that ns is one kernel away from the host stands, and hardening it — uid separation, tenant-versus-tenant escape cases, the Landlock network rules — is where the effort goes instead.

Reopening it

An embedder asking for a hardware boundary per tenant, with a workload where 100 ms of boot per request is affordable. That is a different product shape from the warm fork and should be reasoned about as one, not added to this backend because the flag already exists.

ADR 0003 — No Deno or Bun agent until an embedder asks

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-22. Records the decision taken in Phase 3 of the roadmap, and what would reopen it.

Context

Zygo ships two runtime agents, Python and Node, and a third-party agent can be dropped in with agent = { agent = "/path/in/sandbox" }. Deno and Bun are the obvious next two: both are popular, both start fast, and both would look good in a table.

Neither has an agent, and the reason is what an agent costs to keep. The protocol is only the beginning of it:

  • Each agent is a fork boundary that has to be got exactly right — the child never returns to the parent’s loop, nothing runs before GO, a malformed frame is reported rather than fatal, every EXEC gets exactly one answer. Each of those four rules has been got wrong at least once in this repository, in a language whose semantics its author knew well.
  • Each needs its own answer to ZYGO_CHILD_SECCOMP. Node’s is a forty-line shared object plus a fallback to Node’s permission model, and that fallback was once stricter than the filter it stood in for — a bug the seccomp compatibility matrix caught. Deno has a permission model of its own with different edges; Bun has neither a permission model nor a documented way to reach prctl.
  • Each needs a place in make conformance, the seccomp matrix, and the image matrix — which is where the real cost is. A test that is not run is a claim, and a claim about a runtime nobody is using is a claim nobody checks.

Set against that: Deno and Bun both run JavaScript, and the Node agent already serves it. An embedder who wants Deno usually wants a specific thing — its permission model, its standard library, deno.json — rather than “not V8”.

There is also a shape that costs nothing. Deno and Bun both start in a few milliseconds, so a warm-exec pool (Phase 3.4) serves them today with no agent at all:

[runtime.deno]
image = "denoland/deno:alpine"
cmd   = ["deno", "run", "--allow-none"]

Each request’s script is written into the sandbox and named on the command line, with the event on stdin. What that gives up is streaming, progress(), workspaces and per-request tenant limits — and for a runtime with nothing to amortise, an agent would be buying those four things rather than warmth.

Decision

No Deno or Bun agent. Anyone who wants either has two supported paths: a warm-exec pool, or their own agent against spec/protocol.md, checked with zygo agent test.

Consequences

  • The comparison table says two agents rather than four, which is the honest number.
  • make conformance, the seccomp matrix and the image matrix stay at a size where they are all run on every change.
  • A Deno user’s first experience is a cmd, which is three lines and no protocol — and slower per request than a fork from a warm heap, by the cost of an execve rather than the cost of an interpreter start-up.

Reopening it

Any one of these, and none of them is a guess about the future:

  • An embedder asks, with a workload where the warm-exec pool’s per-request execve is measurably too slow for them. That measurement is the argument, not the runtime’s popularity.
  • A dependency set needs it: deno.json or bun.lockb support in POST /deps is a reason to have a runtime that understands them, and that is a smaller piece of work than an agent.
  • Someone writes one and it passes. A third-party agent that clears zygo agent test — including the child-filter checks, which are the hard part — is an agent this project would rather link to than reimplement.

ADR 0004 — A supervisor upgrade re-warms; there is no --reexec

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-23. Records the time-boxed investigation that closed Phase 4 of the roadmap.

Context

Upgrading Zygo replaces the supervisor process, and its warm sandboxes go with it. The question was whether the new binary could exec over the old one and keep them — passing the zygotes’ descriptors and pids across — so that an upgrade cost nothing.

exec is the right primitive to ask about: it keeps the pid, so the sandbox init processes stay children of the same process and their PR_SET_PDEATHSIG still points at it. Nothing dies merely because the binary changed.

What would have to cross the boundary, though, is not the pids. It is:

  • Every descriptor, deliberately. Each warm function holds an agent socket; each sandbox holds seven namespace descriptors, a /run/secrets directory descriptor and a cgroup directory descriptor. All of them are CLOEXEC, on purpose and in one case after a bug (B-03: thirteen descriptors renumbered with dup2, which clears the flag, were inherited by every tenant program). A hand-over means enumerating exactly which to un-flag, which inverts the property the launcher leans on — “everything closes unless something says otherwise” becomes “everything closes unless this list says otherwise”, and the list is per function, per sandbox, per release.
  • The state that is not in a descriptor. The resolved spec of every function and pool, each one’s counters, logs, secrets and script leases, the gate’s in-flight and queued counts, the idle tiering’s clocks. Serialisable, but a second representation of the supervisor’s whole state, versioned across releases, and wrong in a way nothing would notice until a limit was not applied.
  • The requests in flight, and their callers. This is the part that does not reduce. A request’s answer is owed to a control connection — another descriptor — and the mapping from request id to that connection lives in a reply-router thread that exec destroys. Keeping them means handing over the client sockets too, plus each request’s cgroup, child pid and deadline, and rebuilding the router around replies that may already be sitting in a socket buffer. Anything short of that drops requests, which is the one thing the feature exists to avoid.

Against that, what a restart actually costs:

  • A pool re-warms at about 500 ms per zygote (python:3.12-slim, measured in tests/linux/api_driver.py: “a runtime pool is warm (1 zygote, 502 ms)”; a function, 508 ms). That was before the bytecode layer; the same warm-up is now 154–185 ms on a Raspberry Pi 5, the supervisor’s start included, and 34 ms on the Lima VM with one running (chapter 25). A Node zygote announces READY in 15–43 ms.
  • min_warm bounds the window: the replacement warms before it serves.
  • POST /drain means the old process stops admitting, finishes what it is running and exits — so no request is dropped by the restart itself. The Kubernetes example rolls with maxUnavailable: 0, which means the new pod is ready before the old one is asked to leave.

So the cost of a restart is not dropped requests. It is a fraction of a second of warm-up per zygote, on a schedule the operator chooses, with a drain in front of it.

Decision

No zygo api --reexec. A supervisor upgrade is a restart: drain, exit, start, re-warm. min_warm and maxUnavailable: 0 are what make it invisible to a caller, and both already exist.

Consequences

  • An upgrade costs min_warm × warm-up of cold pool per replica, once, while the replacement comes up behind a drain.
  • A single-replica deployment has a window where the pool is cold and requests are slow rather than failed. Two replicas remove it; the example uses two.
  • The descriptors stay CLOEXEC with no exceptions, which is the property every “did this leak into the tenant’s program?” argument rests on.
  • Zygo has one representation of its own state — the running one. There is no serialised form to keep in step with it.

Reopening it

  • A warm-up measured in tens of seconds, not half of one. A pool whose dependency set takes thirty seconds to import changes the arithmetic, and the fix might still be to make that faster rather than to keep the process.
  • A deployment that cannot have two replicas — a single machine with a single pool and a latency budget that a cold start breaks. That is a real shape, and --reexec is one answer to it; another is a second supervisor on the same host and a load balancer, which needs nothing new.
  • Note what does not reopen it: wanting upgrades to be free. They are already free of dropped requests, which is the part that matters.

ADR 0005 — One warm zygote per script version, and what evicts it

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-23. Answers the open question the first adoption report — a consumer’s own write-up, not in this repository — ends on, with numbers from tests/linux/bench_density.py on the 2-core, 4 GB Lima VM.

Context

The shape that forced this decision is a multi-tenant application: arbitrary tenant code that changes whenever somebody presses Save, hundreds of projects, each with its own mounts and egress allowlist, and per-run secrets. Such a product reaches for zygo run first — a sandbox per event, 171 ms from a Mac shell of which ~23 ms is the sandbox — because the mapping is obvious, and then asks whether zygo serve is meant for its shape at all. Three things had to be decided:

  1. Is one warm zygote per script version the intended shape?
  2. What is the eviction policy when a server has four hundred projects?
  3. Can per-run secrets and per-project mounts vary under one warm zygote, or do they force one zygote per (script, project)?

What was measured

make bench-density on the Lima VM (Ubuntu 24.04, 6.8, aarch64, 2 vCPU, 3.8 GiB), python:3.12-slim, a hundred distinct Python handlers, each served as its own function:

per warm script100 scripts
resident (RSS)21.4 MB2 143 MB
proportional (PSS: shared pages divided among sharers)11.3 MB1 141 MB
time to warm (zygo serve, round trip)109 ms10.9 s

PSS is the honest per-script figure: a hundred zygotes of one image share nearly all of the interpreter’s pages, and the slope is measured between checkpoints rather than from the total. So on this VM about 300 warm Python scripts fit in 4 GB with nothing else running, and a thousand would need 11 GB. The cost of evicting one and warming it again is the 109 ms above plus the handler’s own imports; a paused script (past idle_timeout) keeps its pages resident and costs one write to wake.

The same hundred scripts through one runtime pool (--pool), which is the shape Phase 1 of the roadmap built for exactly this question:

one pool
resident, whatever the count29.5 MB, one zygote
what one more script adds0 kB
second call, over the API, p50 / p991.95 ms / 2.38 ms
a warmed function on the same host, p50 / p991.36 ms / 1.57 ms
the pool’s cost over a warmed handler+0.6 ms p50, +0.8 ms p99

Decision

Yes, one warm zygote per script version is the intended shape for a consumer whose scripts have imports worth amortising — and a runtime pool is the intended shape for one whose scripts do not. The two are not in competition; the numbers say which applies:

  • A version whose handler imports something expensive (an ML model, a large client library) pays that once per zygote and 1.4 ms per call. Its cost is 11 MB PSS resident while warm, and it is warm only while called.
  • A version that is a few lines over the standard library — most such scripts — pays 0.6 ms more per call in a pool and nothing to be resident, because it is not: the pool holds the interpreter and the dependency set, and the script arrives with the request.

A consumer with four hundred projects does not have four hundred warm zygotes; it has as many as were called in the last idle_timeout, and zygo ps shows how many that is.

The eviction policy is idle_timeout and cold_after, per function, and nothing else in the product. A version nobody has called for idle_timeout (default ten minutes) is paused: frozen, resident, one write to wake. Past cold_after (default an hour) it is cold: the sandbox is dropped and only the spec kept, and the next call pays the 109 ms plus imports. An LRU over warm scripts, as examples/workflow-engine keeps, is the consumer’s policy layered on top — it decides which versions to serve at all, and if_changed=True makes asking free — and the product does not second-guess it with a global cap. max_warm is a pool’s ceiling on its own zygotes under load, a different knob. What the product owes the consumer is the number to set the timeouts by, which is the table above, and a 429 rather than a swap storm when the host is full, which it has (capacity is a per-host budget).

Per-run secrets vary under one zygote; per-project mounts and allowlists do not. Secrets are already per request by construction: the names are declared on the function, the values arrive with the request and exist as /run/secrets/<name> only while it runs, never in the zygote. Mounts and the network allowlist are the sandbox’s namespaces, built once at warm-up, so a project’s mounts and allowlist are the function’s, and one script version shared by two projects with different mounts is two functions: {project}-{digest}. That is the naming the guide’s worked example uses, and it is what the adopter’s model already implies — a script belongs to a project.

Consequences

  • The guide documents the warm path as the production shape for a multi-tenant consumer, with the worked example, next to the one-shot section that consumers find first.
  • bench-density is the number to re-measure when the interpreter, the image or the kernel changes; the ADR carries this VM’s, and a Linux host with more memory scales it linearly.
  • Not decided here, and worth an ADR of its own if a consumer needs it: a global warm budget (max_warm_total) that evicts the least-recently called function when the host is near its memory limit. Nothing measured above says it is needed before idle_timeout and a 429 do their work.

ADR 0006 — The memory limit is each request’s, not the function’s

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-26. Prompted by a defect found in the Lima VM (kernel 6.8) with two runtime pools at default limits.

Context

A warm function or a runtime pool serves several requests at once from one sandbox: a zygote, and a process per request under it. Its limits were written on the function’s cgroup — memory.max = mem, memory.oom.group = 1, pids.max, cpu.max — and every generation, the zygote and every request sat below that. The per-request cgroup carried only a tenant’s narrower limits, when a tenant had any.

So mem was one budget for the zygote and every concurrent request together, and the group kill sat at the level that held all of them. One request allocating 2 GB in a pool at mem = 256M was killed by the kernel — inside its own cgroup, as the log said — and so were the zygote, the Node agent’s parked workers and the three requests sleeping beside it, in one event. For a runtime pool those requests belong to different tenants. The book said the opposite: “each request has its own group, so it can be killed alone”.

Decision

mem is written on the leaves and nowhere above them:

  • each request’s own cgroup gets memory.max = mem (and memory.high when swap is allowed, memory.swap.max, memory.oom.group = 1);
  • the zygote’s leaf gets the same, so the warm process is bounded on its own;
  • the function’s cgroup keeps pids.max, cpu.max and the swap ceiling, which are one budget for the function, and has memory.max = max and memory.oom.group = 0 written explicitly, so a function directory left by an earlier Zygo does not keep the shared limit it used to carry.

A tenant’s narrower limits still go on the request’s cgroup; they replace the function’s numbers there rather than nesting under a function ceiling.

What a whole function may use is therefore at most (concurrency + 1) × mem per sandbox. The tenant budget above bounds the sum, as before. No generation-level ceiling was added: with both leaves bounded it could only fire on memory charged to the zygote before a forked child was moved into its request cgroup, and a kill there would again be one that reaches the wrong process.

Consequences

  • A request over mem dies alone. The zygote stays warm; the requests beside it finish. make verify-oom-linux runs three sleepers beside a 2 GB hog for the Python agent and the Node agent and checks exactly that.
  • The Node agent’s parked workers are moved into the request’s cgroup before they run (FORKED carries the pid, and nothing runs before GO), so a worker over the limit is the one killed; the parked ones, in the zygote’s leaf, are not.
  • Memory charged to the zygote’s leaf before a child is moved — the shared, copy-on-write pages of a fork — stays charged there. Only what a request allocates after admission counts against its own mem.
  • A host sized as functions × mem was sized for the old budget. The new bound per function is (concurrency + 1) × mem; chapters 13 and 20 say so.

What would reopen this

  • A kernel that lets a moved process’s charge follow it, which would make a generation ceiling exact and worth adding.
  • An embedder that wants mem to mean the whole function’s budget again, with a measurement of what the per-request shape costs them.

ADR 0007 — A third SDK, in Elixir, in this repository

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-26. Prompted by the first Elixir embedder, a content-management product whose two drivers shelled out to the zygo CLI.

Context

The first product built on Zygo from Elixir ran every script by starting the zygo binary: about a thousand lines across two drivers. Most of those lines worked around being on the far side of a command line. Stdin went through /bin/sh. On macOS the order of stdout and stderr was lost in the shim. Whether a zygote had gone was guessed from the text of an error. A table of which functions were warm lived in ETS behind a global lock, and every event went through a payload file and a chmod.

zygo api already answers every one of those. It returns structured JSON, a status and a code, a request id and Retry-After. It cancels, holds limits and secrets per tenant, and has runtime pools — one pool, ten thousand scripts, which is exactly that product’s shape. What was missing was a client.

Three questions followed: where it lives, what it depends on, and how it reports failure.

Decision

It lives in this repository, as sdk/elixir, and is published to Hex as zygo_sdk. The Python and Node clients already set a contract here: a stand-in API in place of a sandbox, the method table in chapter 17, one test per OpenAPI operation, make test-sdk, make bump, and a release job that refuses an SDK whose version is not the tag’s. A client in another repository would have to follow all of that by hand. The cost is that its version is Zygo’s (0.1.x), as the other two already are.

It has two dependencies, Mint and NimblePool. The other SDKs have none, because their standard libraries have an HTTP client that a unix socket fits under in thirty lines. Erlang’s :httpc can reach a unix socket, but only as a setting of a whole profile — a process under inets, shared by every request made through it — and its connection reuse happens inside it, out of the caller’s sight. The rules the other two clients keep and test (drop a connection idle for 20 s; send again only when nothing of an answer came back) could not be held there. Mint reaches a unix socket per connection, and it is HTTP/1.1 as a data structure rather than a process. A data structure can be lent, and NimblePool is what lends it: one caller at a time, the socket’s ownership moving with it, a caller that crashes taking its socket down with it. Both are from the same authors as Finch; Mint brings one more small package, hpax. req and finch were left out as too heavy for a library, and both want a process started for them. JSON is the standard library’s, so the floor is Elixir 1.18.

It has one exception, Zygo.Error, with a kind. Python has eleven classes because except Busy: is how Python branches. Elixir branches on data, with case and pattern matching, so the eleven become eleven atoms — :busy, :handler, :timeout and the rest — in the same order and with the same fields. Every function returns {:ok, value} or {:error, error}, and a ! twin is generated for each so the two cannot drift.

Consequences

  • Chapter 17’s method table has an Elixir column, and its error table an Elixir kind column. A test reads the error table and checks every row, so the book and the client cannot disagree without a failure.
  • make test-sdk runs three suites. Without Elixir on the machine the third says it skipped rather than failing; CI has a job that installs Elixir 1.18 and a current release and runs it on both.
  • There is no function handle, as there is in Python (client.fn(name)): fn is a keyword in Elixir, and &Zygo.call(client, name, &1) is one line.
  • A stream is a lazy Stream whose last item is {:result, _} or {:error, _}, not an iterator that raises. A caller that wants it to raise uses Zygo.stream!/4.
  • Publishing needs a HEX_API_KEY secret, and the first version has to be pushed by hand to claim the name.

What would reopen this

  • An :httpc that lets a caller hold and reuse its own connections, which would let the client drop Mint.
  • A second Elixir client worth more to its users than this one — at which point it is better to link to it than to keep two.

ADR 0008 — A function may listen on its own loopback; a pool may not

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-26. Prompted by the n8n task-runner round, in which n8n’s own runner launcher could not start inside a sandbox because each runner listens on a local health-check port.

Context

Since Landlock’s network rules (ABI v4, Linux 6.7) Zygo has handled the bind right in every sandbox with a network namespace and granted it nowhere, so no sandbox could open a TCP listener — not on the network, and not on its own loopback either. Under network = "none" the connect right was handled and never granted too, so a sealed sandbox could not even connect to itself. The reasoning was “no mode has ingress, so nothing should listen”.

Three things about that turned out to matter.

Ingress was never Landlock’s job. Nothing from outside reaches a port a sandbox opens: pasta is started with --tcp-ports none --udp-ports none, so it forwards no port in, and under none there is no interface but loopback. That holds on every kernel. Landlock’s bind rule added nothing to it — and only on 6.7 and later; a Debian 12 host (6.1) never had it.

The rule was TCP only. Landlock’s network rights cover TCP. A UDP listener and a unix socket were always allowed. So the rule was not “a sandbox does not listen”; it was “a sandbox does not listen on TCP”.

It broke programs that talk to themselves. Anything that uses a localhost TCP port for its own plumbing — Jupyter kernels, Ray and Dask workers, PyTorch’s distributed runtime, a headless Chrome’s DevTools port, a Java debugger, n8n’s runner launcher, many test suites — failed inside a sandbox on 6.7+ and worked on 6.1. The book described none as “an empty network namespace with only loopback”, which a reader takes to mean loopback works.

What the rule did buy was in one place: a runtime pool. Its requests belong to different tenants and share one network namespace, so a listener on loopback there is a channel from one tenant’s request to another’s. strict, the pool default, already removes the socket calls; Landlock’s bind refusal is the layer under that, for a pool served with another profile.

Decision

The bind right is handled — and never granted — only where the namespace is shared between tenants: a runtime pool. A function’s own namespace, and a zygo run sandbox, may bind and listen on TCP.

  • network = "none", function: Landlock handles neither bind nor connect. The empty namespace is the boundary. A program may listen on loopback and connect to itself; anything off loopback is ENETUNREACH.
  • network = "egress", function: bind is allowed; connect is limited to the allowlist’s ports as before, and that rule applies to loopback too. A function that talks to its own listener therefore names the port in allow (127.0.0.1:5681 needs --allow-private-net), or it uses full or none.
  • network = "full", function: bind is allowed; Landlock handles nothing.
  • A pool, in every mode: bind handled and never granted, as before; under none connect too. SandboxConfig::shared_namespace carries the distinction from the resolved pool to the ruleset.

Nothing changes about ingress: pasta still forwards no port in, and under none there is still nothing but loopback.

Consequences

  • Stock software with local health-check or debugging ports runs inside a function’s sandbox. n8n’s runner launcher is the case that found this.
  • A sealed function behaves the same on 6.1 and 6.8: its loopback works, the rest is unreachable. Before, it depended on the kernel.
  • Two tenants’ requests in a pool still cannot open a channel to each other through a port: make escape-linux case 20 attempts it under strict (refused by seccomp) and under default (refused by Landlock, 6.7+).
  • Under egress, self-connect is the one awkward corner; the book says so and gives the allow rule that opens it.

What would reopen this

  • A function shape that runs several tenants’ requests in one namespace without being a pool. Then shared_namespace is the flag to set, not the rule to revisit.
  • Landlock gaining an address scope for bind, which would let a pool’s scripts listen on ports nobody else in the pool can reach.

ADR 0009 — The API may allow private addresses when its operator says so

A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.

Status: accepted, 2026-09-26. Prompted by the Elixir embedder of ADR 0007, moving its scripts from the zygo CLI to runtime pools over the API.

Context

That product’s scripts read their own project’s tables while they run. A script asks, the application answers, and the script carries on. Under the CLI the question and the answer were files in a directory both sides could see. A runtime pool has no such directory: the script arrives with the request and nothing of the host is mounted into it. The answer that fits a pool is an HTTP call from the script to the application.

Chapter 14 already says how a sandbox reaches a service on its own host. The service listens on an address the host really has, an allow rule names that address and port, and --allow-private-net is typed by a person, because every such address is in a private range. A spec file served by hand can do all three. The API could do the first two and never the third: PUT /fn/{name} and POST /runtimes sent the supervisor allow_private_net: false whatever happened. The comment beside it gave the reason: a caller that could widen the boundary over HTTP would make the flag on the server meaningless.

Two findings came with it. An address written without a prefix, 192.168.1.70:8765, was read as a host name and passed the private-range check, and the firewall then refused every connection to it. And a pool, strict by default, had no socket at all, so a pool with an allowlist reached nothing, allowed hosts included.

Decision

zygo api --allow-private-net sets allow_private_net for what deploy callers serve, on PUT /fn/{name}, POST /runtimes and POST /run. The reason in the old comment still holds and is kept: a request body cannot set it, and deny_unknown_fields still refuses a body that tries. What changes is that the server’s own command line is also “where the sandbox is declared”. The person starting the API types the flag, as they type --allow-deploy.

It widens less than it sounds. It is inert without --allow-deploy, and a deploy caller can already serve any image with any mount as the API’s user, which chapter 17 calls a shell. A rule still names one address and one port, and the rest of every private range stays shut. GET /version reports it as private_net, so an embedder can say “reads are off on this server” instead of failing its first query.

A private address without a prefix is refused like a private CIDR. And a network under strict is refused when the sandbox is declared. Both used to be accepted and then failed on every request with nothing to say why.

Consequences

  • Chapters 14, 17 and 19 describe the flag, and chapter 17’s /version table has private_net.
  • A pool whose scripts call out names seccomp = "default". It already had to in practice; now it is told so when it is served.
  • A spec that paired network = "egress" with strict, or allowed a bare private address without the flag, is refused where it used to be accepted. Neither ever worked.

What would reopen this

  • A channel for a request to ask its caller something mid-run, through the supervisor and the stream, with no network at all. That would suit a network = "none" pool better than any allowlist. It is a protocol change across three agents, three SDKs and the API, and one embedder asking is not yet enough to design it well.
  • A deployment where deploy rights are handed to parties the operator does not trust. There this flag would need to be per token rather than per listener.

Glossary

One line each. The chapter in brackets explains the term in full; the numbers follow the book’s contents.

TermMeaning
ADR“Architecture decision record”: a short note of one design choice, why it was made, and what it costs. [26]
agentThe small program inside a warm sandbox that loads your handler, forks per request and speaks Zygo’s protocol. [6]
backendWhere Zygo draws the wall: ns (host kernel), gvisor (user-space kernel), vm (virtual machine). [6]
bind mountShowing an existing file or folder at a second place; how your code gets into a sandbox. [4]
blobA tar file stored by the API under its hash, sent into a request as its workspace. [17]
blue/greenReplacing a running thing by warming the new one first, then switching; zygo up does this. [16, 19]
BPFA tiny, safe program format the kernel can run; seccomp filters are written in it. [4]
capabilityOne named piece of root’s power, such as CAP_NET_ADMIN; a sandbox drops them all. [1, 4]
cgroupA group of processes the kernel counts and limits together. [3]
cgroup.killA file that kills every process in a cgroup in one write. [3]
chrootThe old way to change a process’s root folder; easy to escape, replaced by pivot_root. [4]
clone3The syscall that starts a child process, optionally in new namespaces. [2]
containerA process with namespaces, a cgroup and filters on it, plus a tool’s records about it. [5]
controllerThe part of cgroups that handles one resource: memory, CPU, pids, I/O. [3]
copy-on-writeSharing memory pages after a fork and copying one only when it is written. [1]
daemonA program that runs in the background waiting for requests, such as dockerd. [5]
delegationGiving a normal user one branch of the cgroup tree to manage. [3]
dependency setA venv built once from lock files sent to POST /deps, for a runtime pool to use. [17]
deploy rightsPermission to create, change or destroy sandboxes through the API, not only call them. [17]
digestsha256:…, the hash that names an image, a layer, a script or a blob exactly. [5, 15]
dynamic linkingA program that loads shared libraries such as libc.so from the machine when it starts; it needs those files inside the sandbox. [6, 12]
egressTraffic going out of the sandbox; Zygo’s egress mode allows only listed names. [4, 6]
exec (execve)Replacing a process’s program with a new one from disk. [1]
favordynmodsA cgroup2 mount option that makes moving a process between cgroups cheap, at a small cost to every fork and exit; zygo doctor --fix can turn it on. [22, 25]
forkMaking a copy of the calling process. [1]
gVisorA kernel written in Go that runs in user space and answers a sandbox’s syscalls. [10]
HexThe package registry for Elixir and Erlang, as PyPI is for Python; the Elixir client is zygo_sdk there. [17]
imageA file system stored as layers, plus a little metadata; the OCI standard defines it. [5]
jailFreeBSD’s kernel object that confines a group of processes. [9]
KVMThe Linux feature that lets it run virtual machines using the CPU’s hardware support. [10]
LandlockA Linux feature that lets a process limit its own file and network access. [4]
layerOne tar file of changes in an image, named by the hash of its content. [5]
layer (of a spec)One sandbox.toml table — [defaults], [fn.NAME] or [runtime.NAME] — with every field optional, merged over the one below; what the API’s PUT /fn/{name} and POST /run bodies carry. [17, 20]
LimaThe tool Zygo uses to run a Linux virtual machine on a Mac. [11]
MCPModel Context Protocol: how an AI agent host starts and talks to a tool server; zygo mcp is one. [17]
microVMA very small virtual machine that boots fast, such as Firecracker’s. [10]
namespaceA separate copy of one kind of kernel table — mounts, pids, network — for a group of processes. [2]
no_new_privsA flag that stops a process and its children from ever gaining power, even through setuid. [4]
OCIThe Open Container Initiative, and its standards for images, registries and runtimes. [5]
OOM killerThe part of the kernel that kills a process when memory runs out; with cgroups, only inside the group. [3]
operatorWhoever runs the Zygo host; an operator token may act for any tenant. [17]
OTLPOpenTelemetry’s protocol for sending metrics to a collector. [17]
outcome fileThe JSON zygo run --outcome writes, saying why a sandbox ended. [21]
overlayfsA file system that stacks folders and shows them as one; how image layers become a root. [4]
p50 / p99The median time, and the time 99 requests in 100 beat. [25]
pastaA program that connects a network namespace to the host’s network in user space, without root. [4]
paused / coldA warm function after idle_timeout (frozen, still in memory) and after cold_after (dropped). [13]
pidA process’s number. [1]
pivot_rootSwapping a mount namespace’s root for a new one, so the old root can be removed. [4]
prefork poolWorker processes forked from one that has already loaded the code, such as Python’s forkserver or gunicorn --preload; fast, with no sandbox of its own. [10]
PSS / RSSMemory a process uses, with shared pages split between sharers (PSS) or counted in full (RSS). [7, 25]
registryA server that stores images, such as Docker Hub or GitHub’s. [5, 15]
rlimitAn old per-process limit, such as the number of open files. [4]
rootuid 0, the user that passes most permission checks. [1]
rootlessRunning without root at any point, thanks to user namespaces. [2, 6]
runtime poolWarm zygotes holding an interpreter and no code; each request brings its own script. [13]
sandbox.tomlThe file that describes a project’s functions, pools and API. [20]
SBOM“Software bill of materials”: the list of every library inside a binary, with versions, published with each release. [11]
seccompA filter on which syscalls a process may make. [4]
session sandboxA sandbox that stays up for minutes or hours with its state kept between commands, as hosted agent platforms sell; the opposite of a call. [10]
setnsThe syscall that joins a namespace that already exists. [2]
setuidA mark on a program that makes it run as its owner, often root. [4]
static linkingA program that carries all its library code inside its own file, so it runs on almost any image. [6, 12]
supervisorZygo’s process that keeps zygotes, hands out requests and enforces deadlines, under your user. [6]
syscallA request from a program to the kernel. [1]
tapA GitHub repository of Homebrew formulas; brew install mhmtskrc2/zygo/zygo installs Zygo from one. [11]
task runnern8n’s name for the process that runs a Code node’s code apart from n8n itself; n8n’s task broker hands it the tasks. [10, 25]
tenantOne customer of whoever embeds Zygo; has its own scripts, secrets, limits and tokens. [14, 17]
tmpfsA file system in memory that disappears when no longer used. [4]
tokenA secret a caller sends to the API to prove who it is: operator or tenant. [17]
uidA user’s number; uid_map translates uids between a user namespace and the host. [1, 2]
user spaceEverything outside the kernel: all normal programs. [1]
warm-execZygo’s mode where the sandbox is kept ready and each request is a new process entered into it. [6]
WebAssembly (Wasm)A portable instruction format that runs inside a runtime’s own process and can reach only what the runtime hands it; code must be compiled for it. [10]
workspaceA folder of files sent in with one request, and optionally returned with the result. [17]
zygo.lockThe file zygo up writes to record which image digests and package versions were used. [20]
zygoteA process that has done its start-up and is forked for every request; the name comes from Android. [6]