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

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"}'