Guided tutorial

Three cells, three ideas.

Each example here runs for real and teaches one thing. By the end you will have run an interpreter on agent-written code, reached the internet from a cell that has no network, and lent two independent toolchains to one cell — and you will have seen the trust model refuse things along the way.

Every output below is copied from a real run. You need Linux with /dev/kvm, and skopeo and e2fsprogs installed.

Setup: what a tool is

A tool in Celln is not a name. It is bytes, hashed, that the host vouches for. Most real tools — python, curl, node — are not one file but a closure: a binary, its loader, and the shared objects it resolves by absolute path. Celln lends that closure as a sealed, read-only filesystem built from an OCI image.

$ celln setup
$ celln image catalogue
  ✔ python     /usr/bin/python /bin/sh      materialised
  ✔ curl       /usr/bin/curl                materialised

The catalogue is compiled into the binary and pinned by digest, never by tag. A tag can be moved; if a spec named one, what your cell is lent could change without your spec changing. Updating a pin is a reviewed commit, which is why a CI job re-resolves the tags weekly and opens a pull request rather than changing anything silently.

Pulling is deliberately its own step. A registry round trip takes seconds to minutes; doing it while building a cell would put the network on the spawn path. A cell only ever maps bytes that already exist.

$ celln image spec python > my.toml   # scaffolds a runnable spec

A · An agent allowed to use python

The idea: the same binary can carry different authority depending on what it is fed.

examples/cells/a-agent-uses-python.toml:

name = "agent-uses-python"

[cell]
memory = "512MiB"

[[tool]]
alias = "/usr/bin/python"
image = "python"
exec  = "/usr/local/bin/python3.12"
interpreter = true

[run]
exec = "/usr/bin/python"
input = "data"                     # the agent wrote what follows
args = ["-c", "…"]

Two lines are doing the work. interpreter = true says this binary executes whatever it is handed. input = "data" says the thing it is being handed came from the agent, not through the attestation gate.

$ celln run examples/cells/a-agent-uses-python.toml
  ✔ /usr/bin/python permitted in the agent lane
  ✔ pilot: /usr/bin/python permitted:agent

agent code running in a cell
  computed: 97e5c19818d31f1152e0193e
  socket: PermissionError - confined to the agent lane

python is fully attested, and this invocation still ran in the agent lane. That is the laundering ban: an attested interpreter fed agent-authored input is demoted for that call, so passing code through a trusted binary is not a way to launder it into trusted authority.

The demotion is not cosmetic. Notice socket() failing — the agent lane adds a seccomp filter and a Landlock boundary immediately before execve. Change input to "none" and the same binary, with the same hash, runs in the tool lane and can open a socket. Same bytes, different authority, decided per invocation.

Try it: flip input = "data" to "none" and watch the lane change in the output. Nothing else about the spec moves.

B · Reaching a host from a cell with no network

The idea: capabilities are brokered, not granted.

A cell has no network stack at all. Put curl in one and it runs perfectly and reaches nothing:

$ celln run examples/cells/curl.toml     # with args = ["https://example.com"]
curl: (28) Resolving timed out after 5002 milliseconds

Egress is a capability the host performs on the cell's behalf, and you have to name who it may talk to:

[cell]
memory = "512MiB"
allow_hosts = ["google.com", "www.google.com"]

[[tool]]
alias = "/fetch"
builtin = "fetch"

[run]
exec = "/fetch"
args = ["https://google.com"]
$ celln run examples/cells/b-fetch-google.toml
  · egress brokered by the host: google.com, www.google.com
  ✔ pilot: /fetch permitted:tool
  ✔ /fetch exit=0
  fetched 82445 bytes; starts: '<!doctype html><html itemscope=""…'

Real bytes, from the real internet, into a cell with no interface. The guest hands a URL over four I/O ports; the host validates it, pins the DNS result before connecting, follows redirects one hop at a time re-authorising each, and bounds the size and the time. Ask for somewhere you did not declare:

pilot-fetch: CELLN_FETCH_ERROR:host "example.com" is not declared in this cell's allowlist

A spec that declares builtin = "fetch" with no allow_hosts is a spec error, not a cell with open egress. Hermetic is the default you have to opt out of, one host at a time.

Why can't curl do this? It would need a socket, so the host would have to broker raw TCP instead of a validated URL — and that discards the DNS pinning, the redirect re-authorisation and the protocol allowlist that make the capability safe. The rule this follows is broker capabilities, never binaries: the cell asks for fetch(url), never for curl(argv).

C · Several tools, from several images

The idea: tools map onto images; they do not own them.

It is tempting to solve "I need python and curl" by finding one big image with both. Don't: that lends a whole distribution — compilers, headers, package managers — to run two commands. Lend a tool and its closure instead, and let a cell mount more than one:

[[tool]]
alias = "/usr/bin/python"
image = "python"
exec  = "/usr/local/bin/python3.12"
interpreter = true

[[tool]]
alias = "/bin/sh"
image = "python"                   # same image: shares the mount
exec  = "/bin/dash"
interpreter = true

[[tool]]
alias = "/usr/bin/curl"
image = "curl"                     # a second, independent image
exec  = "/usr/bin/curl"
$ celln run examples/cells/c-curl-and-python.toml
  · cell sealed, 3 tool(s) lent read-only
  · image mounted at /tools1
  ✔ pilot: /usr/bin/python permitted:tool   exit=0
  ✔ pilot: /usr/bin/curl   permitted:tool   exit=0
  ✔ pilot: /bin/sh         permitted:tool   exit=0

python   OpenSSL 3.5.6 7 Apr 2026
curl 8.21.0 (x86_64-pc-linux-musl) libcurl/8.21.0 OpenSSL/3.5.7 …

Each image becomes its own sealed namespace with its own mount. The clearest evidence they are genuinely separate is in that output: python reports OpenSSL 3.5.6 from a glibc image, curl reports OpenSSL 3.5.7 from a musl one. Two libcs, two TLS stacks, one cell, neither aware of the other.

Because tools reference images rather than owning them, all three shapes are the same mechanism:

ShapeMeaning
one tool, one imagethe ordinary case
many tools, one imagepython and sh above — one mount, one physical copy
many tools, many imagesindependent closures side by side

Sharing is also what makes this cheap. An image is content-addressed, so every cell leasing it maps the same physical copy — sixty-four cells, one allocation. And because the pages are sealed read-only by hardware, one cell cannot alter what another sees.

Adding a tool

The catalogue ships with a small default set, but adding your own is one command. You do not need the digest, and you do not need to know the image's internal layout:

$ celln image add busybox:1.36
● busybox:1.36 → busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662
● pulling …
  · 1 layer(s), 4 MiB
  + 10 MiB  tier=verified  blake3:8a71ce8bd4eae1c1…
  + added busybox to ~/.celln/tools.toml
      /usr/bin/busybox → /bin/busybox
  · use it with: celln image spec busybox > cell.toml

You named a tag, and celln resolved it to the digest it points at right now and pinned that. The tag is remembered only so it can be re-checked later; what gets lent is the digest. It then materialised the image, looked inside it without mounting it, and found the executables worth exposing.

$ celln image catalogue
  ✔ python     /usr/bin/python /bin/sh      materialised
  ✔ curl       /usr/bin/curl                materialised
  ✔ busybox    /usr/bin/busybox             materialised, local

It is usable immediately, exactly like a shipped entry:

$ celln image spec busybox > cell.toml
$ celln run cell.toml
  ✔ pilot: /usr/bin/busybox permitted:tool
  · /usr/bin/busybox exit=0

When the guess needs help

By default celln exposes binaries named after the image. When that is not the binary you need, --tool takes alias=path-in-image: the left side is the stable name used in a spec; the right side is the executable inside the image. For Go:

$ celln image add golang:1.27rc2-alpine3.24 \
    --tool /usr/bin/go=/usr/local/go/bin/go
$ celln image spec golang > go.toml

Celln never inherits the host environment. If a tool requires runtime variables, declare them beside the invocation (or in [agent] for an agent-selected invocation). They are part of the reviewed cell policy and are passed exactly as written after the image is entered:

[agent]
exec = "/usr/bin/go"
prompt = "report the Go version"

[agent.env]
GOROOT = "/usr/local/go"
GOCACHE = "/tmp/go-cache"

Use [run.env] for a pinned [run] instead. Celln does not copy the assay host's environment, and model-authored environment is agent input: it cannot be used to retain tool-lane authority.

If nothing obvious matches, celln refuses and lists what it found rather than guessing. Anything whose name looks like an interpreter is marked as one, so the laundering ban applies to it — check that, because an unmarked interpreter keeps full tool-lane authority even when fed agent-written code.

--default makes celln setup materialise it too. celln image remove <name> drops it.

Local, shipped, or neither

WhereUse it for
celln image add~/.celln/tools.tomlYour own tools. Local entries shadow shipped ones of the same name, so you can pin your own build of something.
A digest straight in a spec — image = "node@sha256:…"One-off or project-specific tools. No catalogue entry needed at all.
crates/celln-cli/tools.toml, via a pull requestTools worth shipping to everyone. CI re-resolves those tags weekly and opens a PR when upstream moves.
Lend a tool, not a distribution. Pick the smallest image that carries the tool and its dependencies — everything in it is lent to every cell that names it, and image pull refuses anything over 512 MiB. If a tool is static it needs no image at all: a spec can lend it directly with path.

What you learned

Next: the concepts in order, or the security boundary for what is enforced and what is not claimed.