The Nightly Build.
Expert Tools

Pydantic Monty pauses a running snippet mid-call and resumes it in a different process

Version 0.0.19 starts in microseconds and blocks the filesystem and network by default, but it still can't run a match statement or import itertools.

exec() gives an LLM's code the same access as the process running it

Agent frameworks increasingly let a model write a short Python script to do the actual work, rather than approve one tool call at a time. Pydantic's own case for the pattern is direct: "LLMs work faster, cheaper and more reliably when they write code instead of making sequential tool calls."1 Whatever the model writes is still model output. Nobody reviews it before it runs, and a model that can be prompted into calling the right function can be prompted into calling the wrong one.

Python's own exec() enforces nothing about what that code is allowed to touch. It runs the string in the calling process, with the calling process's own permissions.

exec() runs whatever the model wrotePYTHON
llm_generated_code = """
import os
print(open("secret.env").read().strip())
"""
exec(llm_generated_code)
# DB_PASSWORD=correct-horse-battery-staple
Fig. 1 · Ten lines, run directly with exec(): the secret file in the working directory prints in plain text.

No permission check ran, and the process's filesystem access became the model's filesystem access. Guards that patch this from inside the same interpreter, stripping builtins or scanning the syntax tree for banned names, still run the untrusted code in the interpreter that also holds the host's secrets. Closing that gap took a second interpreter, in a second process, that was never given the operation in the first place.

Pydantic built that interpreter in Rust and calls it Monty, distributed as pydantic-monty for exactly this job: running untrusted, model-written Python.2 The package on PyPI is at version 0.0.19, released July 24, 2026, MIT-licensed, and needs Python 3.10 or later on the host.3 Pydantic's own figures put the compiled interpreter at roughly 4.5 megabytes, with about 5 megabytes of memory overhead per worker.1 Feed it the identical ten lines:

the same snippet under MontyPYTHON
from pydantic_monty import Monty, MontyRuntimeError

with Monty() as pool:
    with pool.checkout() as session:
        try:
            session.feed_run(llm_generated_code)
        except MontyRuntimeError as e:
            print(e.display("type-msg"))
# PermissionError: Permission denied: 'secret.env'
Fig. 2 · Same code, same file, run through Monty's feed_run instead of exec().1

open() is not missing here. The error is a PermissionError, the kind Python raises when a capability check fails, not the NameError or ModuleNotFoundError it would raise if the call did not exist. Monty's default is zero access: "no filesystem, no network, no environment variables, strict resource limits."1 The interpreter is enforcing that default here, not stumbling over an operation it never built.

A container is the wrong shape for a call this small

The obvious way to isolate that code is a container: a scratch filesystem and network namespace nothing else can see, thrown away when the call ends. It is also not built for a call this small. Pydantic's own comparison table puts Monty's execution at 4.5 microseconds without type checking and 4.8 milliseconds with it turned on, against a Docker container's cold start at roughly 195 milliseconds and a Pyodide WebAssembly runtime at roughly 2,800 milliseconds.1 The project's own README separately clocks a fourth approach, a WASI/Wasmer sandbox reached over a subprocess call, at 66 milliseconds.2 One independent write-up states the container case for agent workloads plainly: Docker, it argues, is "too slow for how AI agents actually work."4

Cold-start latency by sandboxing approach: Monty, Docker, and Pyodide from Pydantic's own comparison, WASI/Wasmer from the project's README.12
Approach Startup latency
Monty 4.5 µs (no type check)
Monty 4.8 ms (type-checked)
WASI/Wasmer 66 ms
Docker 195 ms
Pyodide (WASM) 2,800 ms

The gap survives Monty's own safety cost. Type checking each snippet before running it adds three orders of magnitude over the untyped path, and the 4.8-millisecond, type-checked figure, the one that describes an actual call, is still roughly 40 times faster than Docker's 195.1

The comparison is almost entirely startup and dispatch overhead, not raw execution speed. Once a snippet is running, Monty's own documentation describes runtime performance as similar to CPython, generally "between 5x faster and 5x slower."2 The numbers above measure how fast the sandbox opens the door, not how fast it runs once you are inside.

Every host function is granted by name, not inherited

Blocking everything by default only works if there is a way back in for calls a snippet legitimately needs. Monty's route is external_lookup: a plain dict of host callables, passed once per call, naming the only functions the sandbox can resolve for anything it does not already know.2 Pydantic designs Monty around that same default: everything closed until a name is granted.1

granting one function by namePYTHON
code = """
config = get_config("api_base_url")
try:
    open("secret.env").read()
    leaked = True
except Exception:
    leaked = False
(config, leaked)
"""

result = session.feed_run(
    code,
    external_lookup={"get_config": lambda key: "https://api.internal/v1"},
)
# ('https://api.internal/v1', False)
Fig. 3 · One granted function resolves; the file read two lines later still fails the same way it did in Fig. 2.

The host decided what get_config returns, and Monty never had to know the function existed until that call was handed to it. The file read two lines later fails the same way it did in Fig. 2: not because open() is unresolved, but because filesystem access is still zero by default, and Monty checks that default on every call regardless of what has been granted.1 A function name missing from external_lookup entirely fails a different way, with a plain NameError, though nothing in this snippet takes that path.

A paused call serializes to a few hundred bytes and resumes in another process

Grant a function through feed_run and Monty calls it synchronously, blocking until the host answers. A second entry point, feed_start, does something a subprocess or container sandbox has no equivalent for. Instead of driving straight to completion, it can stop the instant a snippet reaches an external call and hand back a snapshot describing exactly where execution paused.12 The host answers it whenever it is ready.

I ran this against the installed 0.0.19 package myself, against a loop that calls an unresolved get_weather function twice:

pause, dump, resume on a different workerPYTHON
code = """
total = 0
for city in ["Paris", "Tokyo"]:
    total += get_weather(city)
total
"""

with Monty() as pool:
    with pool.checkout() as session:
        snap = session.feed_start(code)
        while not isinstance(snap, MontyComplete):
            if isinstance(snap, FunctionSnapshot):
                blob = snap.dump()          # 549 bytes
                break
            snap = snap.resume_auto()

    # A second, unrelated worker picks the call up.
    with pool.checkout() as session2:
        restored = session2.load_snapshot(blob)
        fake_temps = {"Paris": 18, "Tokyo": 27}
        while not isinstance(restored, MontyComplete):
            city = restored.args[0]
            restored = restored.resume({"return_value": fake_temps[city]})
        print(restored.output)
# 45
Fig. 4 · The dump crosses from the first worker to a freshly checked-out second one and finishes the loop.

The pause happened inside one worker process. snap.dump() returned 549 bytes: opaque state, no live file descriptor, nothing tying it to the process that produced it. I loaded those bytes into a second, freshly checked-out worker Monty treats as unrelated to the first, and answered the paused call with a plain dict. The loop picked up exactly where it stopped, ran its second iteration, and returned the total. Pydantic's own figure for a snapshot's size is "single-digit kilobytes."1 This one, at two iterations, landed under one.

Nothing about a container or a bare subprocess offers the same trade. The process holding a paused call has to stay resident, tying up a worker, until an answer arrives. Monty's snapshot lets that worker go back into the pool the moment a call blocks, and the eventual answer can land on whatever worker Monty checks out next.

One code-writing call replaces a loop of tool calls

The payoff shows up across a full agent turn, not one call. Mike Chambers built a weather agent that writes a short script instead of calling tools one at a time, using asyncio.gather() to run two external lookups at once instead of two sequential round trips. His own framing of the savings: "One round-trip instead of five. Or ten. Or twenty."5 Fewer round trips to the model means less context spent re-describing the conversation each time.

The restricted language pushes toward this shape on its own. Simon Willison, testing an earlier build, noticed that an unsupported construct does not fail silently: the sandbox returns a specific, typed error the model can read and retry against, the way it would handle any other exception.6 A killed container returns nothing about why a call never came back; a NotImplementedError naming the missing construct is something an agent loop can act on directly.

match statements, generators, and most of the standard library still fail

Running each disputed construct against the installed 0.0.19 package settles most of the maturity question directly.

what 0.0.19 runs and what it rejectsPYTHON
class Point:                 # works
    def __init__(self, x, y): self.x, self.y = x, y

with open_cm() as v: ...      # works (context managers)

match x:                      # NotImplementedError:
    case 1: ...                # pattern matching not yet supported

def gen():                    # NotImplementedError:
    yield 1                   # yield expressions not yet supported

import itertools               # ModuleNotFoundError: no module 'itertools'
import functools                # ModuleNotFoundError: no module 'functools'
Fig. 5 · Each line run individually through feed_run against the installed package; comments are the actual results.

A class definition and a with block backed by __enter__/__exit__ both execute cleanly. As of this release, the README and the hosted documentation, dated the same day, still listed class definitions as "support should come soon." The version 0.0.19 release notes themselves list "User-defined classes implementation" as new in this build.7 The documentation had not caught up to the changelog. Running the interpreter directly settles which one was current.

A match statement and a generator function still fail outright, each rejected with an explicit NotImplementedError naming the missing construct, and two of the standard library's most ordinary modules, itertools and functools, are not importable at all. Third-party packages stay out of the sandbox entirely: installing one switches Monty to a separate, embedded-CPython worker mode, a different trade against the isolation the rest of this piece describes.2

An independent test against an earlier build, version 0.0.3, found roughly the same shape of gaps: no class definitions, no match statements, no context managers, no generators, most of the standard library absent.8 Two of those five are resolved as of 0.0.19. Three are not.

The release log moves fast enough to trust; the missing stdlib is real enough to plan around

Trusting a v0.0.x sandbox with untrusted code is a fair thing to ask before adopting it. The release cadence argues for patience paying off. Version 0.0.18, on May 29, 2026, added context managers, a limited open() builtin, and garbage-collection improvements. Version 0.0.19, on July 24, added user-defined classes, a WebSocket transport, and a feed_start API with resume_auto() for driving snapshots automatically: two substantial releases inside three months.7 The open issue tracker names specific, ordinary problems rather than staying quiet: async incompatibilities, hangs on CPU-heavy operations, missing stdlib modules, configuration validation bugs, error messages that do not yet match CPython's.2 That reads as a project still actively finding its own edges, not one that has stopped moving.

The same release that added classes also added WebSocket support, meaning the worker pool this piece has been running locally can run behind a network call instead, the same role a remote code-execution service fills, without leaving Monty's own process model.7

Verdict

Monty earns a place in an agent's code-mode step: short, host-mediated snippets calling granted functions, with no generators or much of the standard library required. Match statements, generators, and most of the standard library still block a script outright, by name, the moment the parser or the import system meets them.7 That list is shrinking release to release, most recently classes and context managers, so it is worth rechecking before ruling Monty out for a workload it cannot handle today.

Sources

  1. Pydantic · Introducing Pydantic Monty
  2. pydantic/monty · README and project documentation
  3. PyPI · pydantic-monty 0.0.19
  4. Suleiman Tawil · Why AI Agents Are Ditching Docker for Interpreter-Level Sandboxing
  5. Mike Chambers · Pydantic Monty: a safe way to run LLM-generated code
  6. Simon Willison · Running Pydantic's Monty Rust sandboxed Python subset in WebAssembly
  7. pydantic/monty · Release history
  8. Tim Hopper · Pydantic Monty: A Secure Python Interpreter for AI Agents