docs/alternatives.md
There are generally two responses when you show people Monty:
Oddly often these responses are combined: people have not found an alternative that works for them, but are incredulous that there is really no better option than writing a Python implementation from scratch.
This page runs through the most obvious alternatives and why they were not right for what we wanted: somewhere to run code written by a model, per request, with nothing else in the loop. All of these technologies are impressive and widely used. Most were not conceived as an LLM sandbox, which is why they are not necessarily great at being one.
The chart is the time to create a sandbox and then run ten REPL commands in it; both halves are measured below.
| Tech | Language completeness | Security | Start latency | FOSS | Setup | File mounting | Snapshotting |
|---|---|---|---|---|---|---|---|
| Monty | partial | strict | 0.08 ms warm, 5 ms cold | free / OSS | easy | easy | interpreter, kilobytes |
| Full Monty | partial, or full via proxy | strict + OS-level | 2 ms warm, 4 ms cold | not free | easy | easy | interpreter, kilobytes |
| Docker | full | good | 195 ms | free / OSS | intermediate | easy | CRIU image, experimental |
| Pyodide | full | poor | 2700 ms | free / OSS | intermediate | easy | no |
| starlark-rust | very limited | good | 1.3 ms | free / OSS | easy | not available? | no |
| WASI / wasmtime | partial, almost full | strict | 16 ms | free / OSS | intermediate | easy | no |
| sandboxing service | full | strict | 1500 ms | not free | intermediate | hard | VM memory image, 100s of MB |
| YOLO Python | full | non-existent | 0.1 ms / 30 ms | free / OSS | easy | easy / scary | no |
Snapshotting means pausing code mid-execution, serialising its state, and resuming it later, possibly elsewhere or more than once. Only an interpreter built for it can do that at the interpreter level; a microVM can do it for its whole memory, at a thousand times the size, and without knowing what the paused code was waiting for. Durable-execution frameworks such as Temporal are not snapshotting: they replay a workflow written for them, which a script a model just wrote is not.
Start latency is the time from requesting a sandbox to receiving the result of 1 + 1.
The agent run below is ten REPL commands against a sandbox that already exists.
Both come from
scripts/startup_performance.py; the
chart adds them.
Start latency measures one execution. An agent in code mode sends several blocks to one environment, each building on the last, so the same script also times ten REPL feeds against a sandbox that already exists, and the chart above adds the two:
| Sandbox | Cold start | Agent run, warm† | Combined |
|---|---|---|---|
| Monty, warm pool | 0.08 ms | 0.4 ms | 0.5 ms |
| Monty, cold start | 5 ms | 0.4 ms | 5 ms |
| Full Monty, client pool already open | 2 ms | 4 ms | 6 ms |
| Full Monty, cold start | 4 ms | 4 ms | 7 ms |
| WASI / wasmtime, precompiled CPython | 16 ms | 180 ms | 200 ms |
Docker, running container, docker exec | 195 ms | 700 ms | 900 ms |
| Sandboxing service, existing Daytona sandbox | 1500 ms | 400 ms | 1900 ms |
| Pyodide, running Deno sandbox | 2700 ms | 35 ms | 2700 ms |
The two Monty rows differ only in whether a worker already exists in the pool; the chart uses the cold one. Full Monty gives every session a fresh worker, so its two rows differ only on the client side: whether the pool object and event loop already exist.
† 10 commands run in a REPL, as you might expect from a simple agent with code mode.
Monty and Full Monty keep the session, so each command is one feed_run.
None of the others has a persistent interpreter to feed: python.wasm is a WASI command module whose _start runs
once, a container or a service runs one program per request, and the Pyodide sandbox evaluates each call in fresh
globals.
For those, command n re-runs commands 1 to n, the cheapest strategy that gives the same result, so the cost is ten
interpreter starts plus the replayed work.
The commands themselves are in AGENT_BLOCKS in the script: a list of orders, a function, comprehensions, json, and
an f-string report; every setup must print the same report.
Every row was measured on 2026-09-03 (Full Monty on 2026-09-04) on an Apple M3 Max (96 GB, macOS 26.5.2) in London, from CPython 3.14.7, with a single sample per cold start unless stated. The tables round the numbers; the measured cold-start values are in the text below.
pydantic-monty 0.0.21 with a release build of the monty worker binary, driven through [Monty()][pydantic_monty.Monty] /
pool.checkout() / session.feed_run(), the package's only execution API.
Cold start creates the pool, which spawns the worker subprocess, completes the protocol handshake, checks out a
session and runs 1 + 1; the median of 7 runs is 4.5 ms.
Warm pool is the median of 20 checkout() + feed_run() round trips against a pool whose worker already exists.
The agent run is ten feed_run calls on one checkout, so state persists and nothing is replayed.linux/arm64 build) running in Docker
Desktop 29.6.2 on the same machine, dialled with pydantic-monty 0.0.22's [AsyncMontyWebsocket][pydantic_monty.AsyncMontyWebsocket] over
ws://localhost.
The client runs in a second container on the same host so the figure is the server's own overhead over loopback,
not Docker Desktop's port-forwarding proxy.
Cold start creates the client pool and opens the WebSocket connection, on which the server spawns a worker for the
session, then checks out a session and runs 1 + 1; the median of 7 runs is 3.5 ms.
The client-pool row is the median of 20 further checkout() + feed_run() round trips on that pool, at 1.6 ms;
each is a new connection and a new worker, because the server never lets one process serve two clients.
The worker spawns inside the Linux container, where Monty's own cold start measures 2.4 ms against 4.5 ms on
macOS, so the Full Monty rows are not directly comparable with the macOS rows above.
The agent run is ten feed_run calls on one checkout, each a WebSocket round trip to the same worker.python.wasm
plus its lib/ directory, preopened as / with PYTHONHOME=/) run in-process through the
wasmtime 48.0.0 Python package.
The module is compiled once to a .cwasm file ahead of time, as a deployment would; the timed cold start deserialises
it (about 1.5 ms), instantiates, and runs python -c 'print(1 + 1)', which is dominated by CPython's own startup
inside the module.
Compiling from wasmtime's cache instead costs about 95 ms, and from scratch about 340 ms.
The agent run deserialises once and creates one Store per command, replaying the earlier commands; deserialising a
new module while the previous store is still alive would add about 200 ms of page faults per command.python:3.14-alpine image already pulled.
Cold start is docker run --rm python:3.14-alpine python -c 'print(1 + 1)'.
The agent run keeps one container alive (docker run -d --rm python:3.14-alpine sleep infinity) and executes each
replayed program with docker exec <container> python -c ..., so it pays for docker exec and a CPython start per
command but not for a container start.daytona 0.207.0 SDK, sandboxes in Daytona's EU
region, called from London.
Cold start is Daytona().create() followed by sandbox.process.code_run("print(1 + 1)").
The agent run creates a sandbox, warms it with one call, then makes ten code_run calls with the replayed programs,
so each command is one HTTPS round trip plus a CPython start on the sandbox; the sandbox is deleted afterwards.
Daytona advertises sub-90 ms sandbox creation; the 1.5 s measured here includes the network round trips from London.mcp-run-python 0.0.22, which starts a Deno 2.5.5 process
running Pyodide 0.28.2 and exposes it as an MCP server over stdio.
Cold start is code_sandbox(), which spawns Deno and loads Pyodide, followed by one eval; installing a package such
as numpy at start adds about 200 ms more.
The agent run reuses a started sandbox and makes ten eval calls with the replayed programs; each call is an MCP
round trip into the already-loaded Pyodide, which keeps no globals between calls.starlark-pyo3 2026.1.1, in-process; the 1.3 ms is the
first parse + eval after import, later evaluations take about 0.01 ms.
It has no agent-run row because the commands are Python, not Starlark.eval("1 + 1") in the measuring process (about 0.1 ms) and python -c 'print(1 + 1)' as a
subprocess (about 30 ms).
Replaying the agent run through ten subprocesses takes about 180 ms; ten exec calls into one namespace take 0.3 ms.pip install pydantic-monty or npm install @pydantic/monty, about 4.5 MB download.feed_start() pauses at a host call and dump() serialises the interpreter, paused call stack
included, to a few kilobytes; restore it once to resume, or several times to fork.
See snapshots.Full Monty is the commercial server: the same monty workers behind a WebSocket, as a container image.
AsyncMontyWebsocket][pydantic_monty.AsyncMontyWebsocket], ships in the MIT pydantic-monty
package.MountDir][pydantic_monty.MountDir] as a local pool.python:3.14-alpine is 50 MB
and Docker cannot be installed from PyPI.See starlark-rust.
CPython compiled to WebAssembly (WASI), run by wasmtime.
socket.socket() and subprocess.run() raise OSError, threading.Thread.start() raises
RuntimeError, and ctypes does not import..cwasm file ahead of time, as a deployment would; about 95
ms when wasmtime compiles from its cache and about 340 ms compiling from scratch.
Measured in-process through the wasmtime Python package with the CPython
3.14.7 WASI build.pip install wasmtime plus a CPython WASI build, a 13 MB download that unpacks to about 54 MB
with the standard library; you manage the module, its precompilation and the stdlib directory yourself.Services like Daytona, E2B and Modal. Running your own sandbox setup on Kubernetes has similar characteristics, with more setup complexity but lower network latency.
Running Python directly via exec() (about 0.1 ms) or a subprocess (about 30 ms).
exec(), about 30 ms for a subprocess.pickle can save the globals between blocks, which is a session dump, not a paused frame.