Skip to content

Monty

Monty is Pydantic's sandbox interpreter for agent code: a restricted Python subset, written in Rust, with no filesystem or network, snapshots of the whole execution state, and external functions the host provides. Pynox runs full CPython. They solve different halves of the same problem.

Key features

  • Monty runs the model's glue code: decisions, loops, tool calls, pause and resume
  • Pynox runs the heavy code: pandas, numpy, any library, real CPython semantics
  • The host owns the bridge and fixes permissions per call
  • The complete example below runs in Pynox's test suite

Why both

Monty Pynox
Engine Own interpreter, Python subset CPython 3.11–3.15
Libraries 24 reimplemented stdlib modules Everything, including C extensions
Isolation No OS surface at all Kernel policy: Landlock, seccomp, limits
Startup Under 1 ms from its pool About 5 ms from the Pynox pool
Snapshots Dump and resume mid-execution No; fresh process per task
Platforms Python, JavaScript, Rust bindings Linux binary, JSONL protocol

Glue code rarely needs libraries; data work always does. Running glue in Monty keeps it tiny, deterministic and resumable. Handing the heavy part to Pynox keeps the ecosystem and the semantics of production Python.

The bridge

Monty calls functions you register with external_lookup. Register one that runs code on a Pynox pool:

def run_python(code: str, timeout: str = "10s") -> dict:
    result = pool.run(code, name="heavy.py", allow_read=[work_dir], allow_write=[work_dir], timeout=timeout)
    return {"exit": result.exit, "stdout": result.stdout, "error": result.error}

The model's glue code, inside Monty, uses it like any function. Permissions are not part of the signature, so the model cannot widen them:

import json

job = run_python('''
import json, statistics
values = [3, 9, 27]
summary = {"mean": statistics.mean(values), "count": len(values)}
with open("/work/stats.json", "w") as f:
    json.dump(summary, f)
print(json.dumps(summary))
''')
if job["exit"] != 0:
    error = job["error"]
    raise RuntimeError(f"{error['type']} at line {error['line']}: {error['message']}")
json.loads(job["stdout"])["mean"]

The heavy code writes a file. The glue code could not have: Monty has no filesystem. Pynox allowed exactly one directory, chosen by the host.

Complete example

Install the optional dependency and run the example against a local build:

uv sync --group integration        # includes pydantic-monty
PYNOX_BIN=$PWD/target/release/pynox uv run python examples/monty_bridge.py
# mean: 13
# stored: {"mean": 13, "count": 3}
examples/monty_bridge.py
"""Monty orchestrates, Pynox executes: two sandboxes, one agent.

The model writes glue code that runs in Monty (pydantic-monty), a restricted
Python subset with no filesystem or network. When that code needs real Python
with libraries, it calls ``run_python(code)``: an external function the host
resolves on a Pynox pool. The host, not the model, decides what the heavy code
may read and write. Requires ``pip install pydantic-monty`` and Linux.
"""

from __future__ import annotations

import os
from pathlib import Path

import pydantic_monty
import pynox

# What an LLM would write for the task "average these readings and store them".
# It runs inside Monty; `run_python` is the only way out of its sandbox.
AGENT_CODE = """
import json

job = run_python(\'\'\'
import json, statistics
values = [3, 9, 27]
summary = {"mean": statistics.mean(values), "count": len(values)}
with open("{work_dir}/stats.json", "w") as f:
    json.dump(summary, f)
print(json.dumps(summary))
\'\'\')
if job["exit"] != 0:
    error = job["error"]
    raise RuntimeError(f"{error['type']} at line {error['line']}: {error['message']}")
json.loads(job["stdout"])["mean"]
"""


def run_agent(work_dir: str | os.PathLike[str], *, binary: str | os.PathLike[str] | None = None):
    work_dir = Path(work_dir)
    with pynox.Pool(workers=1, binary=binary) as pool, pydantic_monty.Monty() as monty:

        def run_python(code: str, timeout: str = "10s") -> dict:
            # Permissions are fixed here. The model never sees or chooses them.
            result = pool.run(
                code,
                name="heavy.py",
                allow_read=[work_dir],
                allow_write=[work_dir],
                timeout=timeout,
            )
            return {"exit": result.exit, "stdout": result.stdout, "error": result.error}

        with monty.checkout(script_name="agent.py") as session:
            return session.feed_run(
                AGENT_CODE.replace("{work_dir}", str(work_dir)),
                external_lookup={"run_python": run_python},
            )


if __name__ == "__main__":
    import tempfile

    with tempfile.TemporaryDirectory(prefix="pynox-monty-") as temporary:
        print("mean:", run_agent(temporary))
        print("stored:", (Path(temporary) / "stats.json").read_text())

What each layer guarantees

Layer Runs Guarantees
Monty The agent's logic No access to the host. Can pause at run_python and be snapshotted while Pynox works
Pynox pool The heavy code New process per job, forked from a warm template. Reads and writes only where granted; no subprocesses; no network unless granted
Host The bridge Decides permissions per call. The model asks "run this"; the host says where

Without Monty

If you only need the model's code to call your functions, Pynox can do that by itself: pynox.tool(functions={...}) exposes them inside the sandbox as pynox_host. Monty adds snapshots and a much smaller interpreter for the glue; choose it when pausing and resuming the agent matters.

Limits

  • Pynox cannot snapshot a running CPython. If the heavy code must survive a host restart, split it into idempotent steps and let Monty hold the state.
  • install_dependencies in Monty 1.1 installs packages into Monty's own worker; libraries that need inheritance, generators or C extensions still belong on the Pynox side.
  • Both sandboxes are controlled automation, not hostile-code containment. Read Monty's security model and Pynox's sandbox contract.

Next steps