{"slug": "pydantic-monty-you-probably-don-t-need-a-full-sandbox", "title": "Pydantic Monty: you probably don't need a full sandbox", "summary": "Pydantic has open-sourced Monty, a minimal, secure Python interpreter written in Rust for running code written by AI agents, with the goal of letting LLMs write Python instead of making sequential tool calls. Pydantic CEO Samuel Colvin said the project positions itself just to the right of tool calling on the control-versus-capability spectrum, exposing only external functions the developer explicitly provides rather than full sandbox containers from services like Modal, E2B, Cloudflare and Daytona. Colvin noted bashkit already supports Monty for running Python code and that Vercel is keen to adopt Monty once its JavaScript API is more complete.", "body_md": "We've built and open-sourced [Monty](https://github.com/pydantic/monty) — a minimal, secure Python interpreter written in Rust, for running code written by AI agents.\n\nIt got [a lot](https://x.com/samuelcolvin/status/2019604402399768721) of [attention](https://www.linkedin.com/posts/samuel-colvin_fuck-it-bit-early-but-here-goes-monty-share-7425371259921686529-dl9J) on social media. Now I want to explain why I'm excited about Monty, with more nuance than 280 characters allows.\n\nLLMs work faster, cheaper and more reliably when they write code instead of making sequential tool calls. Anthropic [wrote about it](https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling) [twice](https://www.anthropic.com/engineering/code-execution-with-mcp), Cloudflare [coined the term \"CodeMode\"](https://blog.cloudflare.com/code-mode/) [and now use it in the MCP server](https://blog.cloudflare.com/code-mode-mcp/), HuggingFace [built something similar a year ago](https://github.com/huggingface/smolagents).\n\nThis isn't controversial; the controversy starts when we need to decide how to run the code.\n\nI want to convince you that Monty provides an excellent solution for code execution for many use cases.\n\n## \n\nWhen you harness an LLM to do useful work, you're making a trade-off between how much control you retain and how much capability you grant. At one extreme, the LLM picks a function name and fills in some JSON. At the other, you've handed a neural network your mouse and keyboard.\n\nBetween those poles sit several distinct approaches, each with its own profile for control, capability, cost, complexity and setup burden. Here's how they compare:\n\nNotice where Monty sits: just to the right of tool calling. That positioning is deliberate.\n\n### \n\nTool calling is where most agents start. The LLM picks a function, provides JSON arguments, you execute it, return the result, and the LLM decides what to do next. It's safe, it's predictable, and it's excruciatingly sequential. Need to call function A, then function B with the output of A, and finally C with the output of B? That's three or four round-trips to the LLM.\n\nMost agents running in the cloud today are using tool calling, but tool calling slows down and restricts the models so much that they're often prevented from performing tasks we've become used to them completing with coding agents - LLMs equipped with tool calling are somewhat impotent.\n\n### \n\nMonty lets the LLM write Python instead. Rather than picking one tool at a time, it can express loops, conditionals, parallel async calls, data transforms — all the things Python is good at. The difference from just calling `exec()` or `eval()` is that Monty provides a custom Python runtime where the only way for Monty code to interact with the outside world is through external functions you explicitly provide.\n\nThe tools you would generally register directly with the LLM are exposed to the LLM as functions that it can call from code; this is what Cloudflare calls **\"CodeMode\"**.\n\nIt's worth noting that Monty is not the only project with this rough design and philosophy, there's also [just-bash](https://github.com/vercel-labs/just-bash) from Vercel and [bashkit](https://github.com/everruns/bashkit) - notably bashkit already supports Monty to run Python code, and Vercel are keen to adopt Monty once our javascript API is more complete.\n\n### \n\n[Modal](https://modal.com), [E2B](https://e2b.dev), [Cloudflare](https://developers.cloudflare.com/sandbox/), [Daytona](https://daytona.io) and the like — give you full CPython in a managed container. Any library, any code. The trade-off is a network call to spin up that container, cold starts measured in seconds, per-execution cost, and above all an external dependency that enterprise security teams tend to have strong feelings about.\n\nDespite the downsides, sandbox services provide LLMs with enormous capabilities and as a result are currently experiencing a very rapid increase in adoption.\n\n### \n\nClaude Code, Codex, Cursor, and similar — get terminal access, browser access via Playwright, the works. Tremendously capable, but you've largely delegated control. These are tools you use interactively, not components you embed in your agent. Generally these only work if you have a human developer tending them, reversing them out of the ditch whenever they crash.\n\nMy impression is that coding agents are currently the bleeding edge of giving LLMs control for most use cases - and we're using them increasingly for non-coding tasks. While they're very powerful, they're rarely able to run fully autonomously (e.g. deploy and leave for weeks) - the solution space is just too large, so the outcomes are just too varied for most real-world applications.\n\n### \n\nThis is the logical extreme: mouse and keyboard control, the LLM driving your desktop. You've federated everything you can do to a neural network controlled by a company that built its empire by breaching IP conventions.\n\n## \n\nThere's a conventional approach to sandboxing that goes roughly like this: start with a full VM or container — everything enabled, full access — then progressively lock it down. Restrict the network. Restrict the filesystem. Restrict syscalls. Keep restricting until it's \"safe enough\".\n\nThis is working backwards. You start with everything and try to remove the dangerous parts. The attack surface is enormous and you're playing whack-a-mole with escape vectors. Every OS has different isolation primitives. Every capability you restrict is a potential misconfiguration. Cursor published an [excellent post on agent sandboxing](https://cursor.com/blog/agent-sandboxing) that show just how painful this approach is across platforms.\n\nMonty's approach is the opposite: start from nothing, then selectively grant capabilities. The default is zero access — no filesystem, no network, no environment variables, strict resource limits. You explicitly opt in to each capability via external functions that you wrote, you control, and you can audit.\n\nThis is the difference between a firewall that blocks known-bad ports and one that blocks everything, then allowlists specific traffic.\n\nAs Monty matures — and we make it easy to provide shims to popular libraries like requests, polars, duckdb, playwright — capabilities move rightward on the continuum. But always by explicit addition, never by failing to restrict something that was there all along.\n\n## \n\nMonty is a Python interpreter written in Rust. Not CPython-with-restrictions. Not Python compiled to WASM. A from-scratch bytecode VM that uses [Ruff's](https://github.com/astral-sh/ruff) parser to turn Python source into its own bytecode format.\n\nWhat it supports:\n\n- Functions (sync and async), closures, comprehensions\n- f-strings, type hints, dataclasses when defined on the host\n- `sys` ,`typing` ,`asyncio` ,`pathlib` standard library modules.`re` ,`datetime` ,`json` coming soon\n- External function calls — the mechanism for interacting with the host\n- Snapshotting — serialize execution state mid-flight to bytes, resume later or elsewhere\n- Type checking — ships with [ty](https://docs.astral.sh/ty/) bundled in the binary\n- Memory, recursion and execution time limits within the interpreter\n- REPL support - from our testing LLMs strongly assume a REPL - that functions and values it previously defined are available when code is next executed\n\nWhat it doesn't support:\n\n- Classes - coming soon\n- Match statements - coming soon\n- context managers - coming soon\n- Full standard library - we'll add more over time as and when the LLM wants to use it\n- Third-party packages - Monty will probably never support 3rd party libraries.\n\nHere's the hello-world+ from the README:\n\n```\n# /// script\n# dependencies = [\n#     \"pydantic-monty>=0.0.7\",\n# ]\n# ///\nimport pydantic_monty\n\ncode = \"print(f'{get_greeting(tone='friendly')} {place}')\"\ntype_stubs = \"\"\"\ndef get_greeting(tone: str) -> str:\n\t...\nplace: str\n\"\"\"\nm = pydantic_monty.Monty(\n    code,\n    inputs=[\"place\"],\n    external_functions=[\"get_greeting\"],\n    type_check=True,\n    type_check_stubs=type_stubs,\n)\n\ndef get_greeting(tone: str) -> str:\n    return \"Hello\" if tone == \"friendly\" else \"Greetings\"\n\nm.run(inputs={\"place\": \"World\"}, external_functions={\"get_greeting\": get_greeting})\n```\n\nThe LLM writes the code in the `code` string. Monty parses it, type-checks it against the stubs, compiles it to bytecode, and executes it — calling back into your Python (or Rust, or JavaScript) code whenever it hits an external function.\n\nAs per [this](https://gist.github.com/samuelcolvin/141d84fb5a304e4f104af2204cbb0f4a) gist, time taken to run the above code (returning the string, instead of printing it) takes:\n\n- 4.8ms with type checking enabled\n- 4.5μs without type checking (yes, microseconds)\n\n## \n\n### \n\n| Approach | Start latency | \n|---|---|\n| **Monty** | **0.004ms** | \n| Docker | 195ms | \n| Sandbox services | ~1000ms+ | \n| Pyodide | 2800ms | \n\nMonty starts in microseconds because it's embedded in the parent process.\n\n### \n\nTwo components matter: execution cost and state storage cost.\n\nExecution: Monty runs in your process. No extra infrastructure, no per-execution billing, no container compute time.\n\nState storage: a CPython process can't be serialised at all. A micro-VM snapshot runs to gigabytes. A Monty snapshot is single-digit kilobytes. If you're building agents that pause and resume — say, waiting for human approval — this difference is not academic.\n\n### \n\n```\nuv add pydantic-monty\n```\n\n(or `pip install pydantic-monty` for the boomers)\n\nOr in JS/TS:\n\n```\nnpm install @pydantic/monty\n```\n\nThat's it. No Docker daemon, no cloud account, no API keys.\n\nThe package is ~4.5MB.\n\nUsing Monty from cpython adds about 5MB of memory.\n\n### \n\nMonty is a Rust binary with no native dependencies. It runs on Linux, macOS, Windows, and anywhere else you can compile Rust — embedded systems, edge devices, the lot. Imagine you want to give a local model sandbox access in a car, or in space - monty should be about the easiest and safest way to provide an agent with code execution capabilities.\n\n## \n\n### \n\nHere's a practical comparison. We have a weather agent with three tools: `get_lat_lng`, `get_temp`, and `get_weather_description`. The task: \"Compare the weather of London and Paris.\".\n\nHere we're using the `CodeExecutionToolset` which will [soon](https://github.com/pydantic/pydantic-ai/pull/4153) land in [Pydantic AI](https://pydantic.dev/pydantic-ai).\n\nHere's the full code for the example:\n\n``` python\nimport asyncio\nimport json\n\nimport logfire\nfrom httpx import AsyncClient\nfrom pydantic_ai import Agent, RunContext\nfrom pydantic_ai.toolsets.code_execution import CodeExecutionToolset\nfrom pydantic_ai.toolsets.function import FunctionToolset\nfrom typing_extensions import TypedDict\n\nlogfire.configure()\nlogfire.instrument_pydantic_ai()\n\nclass LatLng(TypedDict):\n    lat: float\n    lng: float\n\nweather_toolset: FunctionToolset[AsyncClient] = FunctionToolset()\n\n@weather_toolset.tool\nasync def get_lat_lng(ctx: RunContext[AsyncClient], location_description: str) -> LatLng:\n    \"\"\"Get the latitude and longitude of a location.\"\"\"\n    r = await ctx.deps.get(\n        'https://demo-endpoints.pydantic.workers.dev/latlng',\n        params={'location': location_description},\n    )\n    r.raise_for_status()\n    return json.loads(r.content)\n\n@weather_toolset.tool\nasync def get_temp(ctx: RunContext[AsyncClient], lat: float, lng: float) -> float:\n    \"\"\"Get the temp at a location.\"\"\"\n    r = await ctx.deps.get(\n        'https://demo-endpoints.pydantic.workers.dev/number',\n        params={'min': 10, 'max': 30},\n    )\n    r.raise_for_status()\n    return float(r.text)\n\n@weather_toolset.tool\nasync def get_weather_description(ctx: RunContext[AsyncClient], temp: float) -> str:\n    \"\"\"Get the weather description from the temperature.\"\"\"\n    r = await ctx.deps.get(\n        'https://demo-endpoints.pydantic.workers.dev/weather',\n        params={'temp': temp},\n    )\n    r.raise_for_status()\n    return r.text\n\nagent = Agent(\n    'gateway/anthropic:claude-sonnet-4-5',\n    toolsets=[weather_toolset],\n    # toolsets=[CodeExecutionToolset(toolset=weather_toolset)],\n    deps_type=AsyncClient,\n)\n\nasync def main():\n    async with AsyncClient() as client:\n        await agent.run('Compare the weather of London and Paris.', deps=client)\n\nif __name__ == '__main__':\n    asyncio.run(main())\n```\n\n### \n\nWith standard tool calling (`toolsets=[weather_toolset]`), this task requires four sequential LLM round-trips:\n\n- start, decide to call `get_lat_lng` twice for London and Paris,\n- receive the result of the first function, call `get_temp` twice for London and Paris,\n- receive the result of the second function, call `get_weather_description` twice for London and Paris,\n- receive the result of the third function, summarise the result and return the final summary\n\nMany models don't even do as well as that, and make one function call at a time, increasing the task to 7 round trips.\n\nIn this example with sonnet 4.5, this took:\n\n- 12.2s\n- 4.1k input tokens\n- 480 output tokens\n- cost $0.019\n\n**Here's a public trace from [Logfire](/logfire) showing the full flow ([View full trace full screen](https://logfire-us.pydantic.dev/public-trace/445c607c-f6de-486b-806d-3f89c1b3e490?spanId=fb6549163d563c42)):**\n\n### \n\nWith Monty (`toolsets=[CodeExecutionToolset(toolset=weather_toolset)]` enabled), the LLM writes one Python block (this is actual output generated by `claude-sonnet-4-5`):\n\n```\n# Get coordinates and weather data for both London and Paris\nresults = await asyncio.gather(\n    get_lat_lng(location_description=\"London\"),\n    get_lat_lng(location_description=\"Paris\")\n)\n\nlondon_coords = results[0]\nparis_coords = results[1]\n\n# Get temperatures for both cities\ntemps = await asyncio.gather(\n    get_temp(lat=london_coords[\"lat\"], lng=london_coords[\"lng\"]),\n    get_temp(lat=paris_coords[\"lat\"], lng=paris_coords[\"lng\"])\n)\n\nlondon_temp = temps[0]\nparis_temp = temps[1]\n\n# Get weather descriptions for both temperatures\ndescriptions = await asyncio.gather(\n    get_weather_description(temp=london_temp),\n    get_weather_description(temp=paris_temp)\n)\n\nlondon_description = descriptions[0]\nparis_description = descriptions[1]\n\n# Return the results\n{\n    \"London\": {\n        \"temperature\": london_temp,\n        \"description\": london_description,\n        \"coordinates\": london_coords\n    },\n    \"Paris\": {\n        \"temperature\": paris_temp,\n        \"description\": paris_description,\n        \"coordinates\": paris_coords\n    }\n}\n```\n\nTwo LLM calls, one script. One line of code changed.\n\nIn this example with sonnet 4.5, this took:\n\n- 9.1s\n- 3.3k input tokens\n- 493 output tokens\n- cost $0.017\n\nThe saving is relatively modest because the example is relatively simple, the saving increases as the complexity of the task increases.\n\n**Here's a public trace from Logfire showing the full flow ([View full trace full screen](https://logfire-us.pydantic.dev/public-trace/7552fb33-d3bc-4cb0-88b7-3914679f8756?spanId=6ef06180c1cf6d31)):**\n\n### \n\nThe weather example is illustrative but simple. Here's a more involved case: extracting structured pricing data from LLM provider websites, full source in the Monty repository [here](https://github.com/pydantic/monty/blob/2896169c44df8d0ff28f54442f2f5cb16b1d18f9/examples/web_scraper/README.md).\n\nHere we're **\"moving right\"** on the above diagram - giving the LLM significantly more capabilities and relaxing the constraints on the solution space to allow the LLM to solve a more complex problem.\n\nYou might consider this as \"curated computer use\": we're building a pythonic API to let the LLM control some aspect of the computer.\n\nThe challenge is that pricing pages contain far too much HTML to fit in an LLM's context window. You can't just dump the page and ask the model to parse it. Instead, the agent needs to fetch the HTML, use BeautifulSoup to navigate and extract the relevant sections, then record structured data for each model it finds.\n\nTwo capabilities are available in code:\n\n- **Playwright** - allows interaction with a browser through playwright\n- **Beautiful Soup** - interface to calling the[`beautifulsoup4`](https://pypi.org/project/beautifulsoup4/) library.\n\nThe way we expose Beautiful Soup to Monty is via a `beautiful_soup` function which returns a `Tag` dataclass. Although you can't yet define classes in Monty, you can return dataclasses from external functions, and the code can access their attributes and call their methods.\n\n``` php\ndef beautiful_soup(html: str) -> Tag:\n    \"\"\"Parse html with BeautifulSoup and return a `Tag`.\"\"\"\n    element = BeautifulSoup(html, 'html.parser')\n    assert isinstance(element, BsTag), f'Expected a BeautifulSoup Tag, got {type(element)}'\n    return Tag(\n        name=element.name,\n        attrs=dict(element.attrs),\n        string=element.string,\n        text=element.get_text(),\n        html=str(element),\n    )\n\n@dataclass\nclass Tag:\n    name: str\n    attrs: dict[str, str | list[str]] = field(default_factory=dict)\n    string: str | None = None\n    text: str = ''\n    html: str = ''\n\n    def find(\n        self, name: str | None = None, attrs: dict[str, str] | None = None, string: str | None = None\n    ) -> Tag | None:\n        \"\"\"Find the first descendant tag matching the criteria.\"\"\"\n        # bs4's types are horrible, this is the easiest work around\n        result = _parse(self.html).find(name, cast(Any, attrs), string=cast(Any, string))\n        if result is None:\n            return None\n        else:\n            return _from_beautifulsoup(result)\n\n    def select(self, selector: str) -> list[Tag]:\n        \"\"\"Find all descendants matching a CSS selector.\"\"\"\n        return [_from_beautifulsoup(r) for r in _parse(self.html).select(selector)]\n\n    ...\n```\n\n*(This example is heavily truncated, full code [here](https://github.com/pydantic/monty/blob/2896169c44df8d0ff28f54442f2f5cb16b1d18f9/examples/web_scraper/external_functions.py#L164-L234))*\n\nIn the above example of beautiful soup we recreated the bs4 tag within each method on the `Tag`, hence avoiding the need to pass the actual bs4 library tag through Monty.\n\nSometimes we can't get away with that trick, in the case of playwright, we need access to the actual browser session and it's too slow or disruptive to open a new browser page for every method call.\n\nTo accomplish this, we use the following pattern:\n\nThere's an `open_page` method registered with Monty:\n\n``` python\nfrom playwright.async_api import Page as PwPage\n\n...\n\npw_pages: dict[int, PwPage] = {}\n\n@dataclass\nclass Browser:\n    _pw_browser: PwBrowser\n\n    async def open_page(\n        self,\n        url: str,\n        wait_until: Literal['commit', 'domcontentloaded', 'load', 'networkidle'] = 'networkidle',\n    ) -> Page:\n        \"\"\"Open a URL in a headless browser and return a `Page`.\n\n        Use this to load a web page so you can inspect its HTML content.\n\n        Args:\n            url: The URL to navigate to.\n            wait_until: When to consider navigation complete:\n                `'commit'` — after the response is received,\n                `'domcontentloaded'` — after the `DOMContentLoaded` event,\n                `'load'` — after the `load` event,\n                `'networkidle'` — after there are no network connections for 500ms.\n        \"\"\"\n        from .external_functions import Page\n\n        page = await self._pw_browser.new_page()\n        await page.goto(url, wait_until=wait_until)\n        page_id = id(page)\n        pw_pages[page_id] = page\n        return Page(\n            url=page.url,\n            title=await page.title(),\n            html=await page.content(),\n            id=page_id,\n        )\n```\n\n*(This example is heavily truncated, full code [here](https://github.com/pydantic/monty/blob/2896169c44df8d0ff28f54442f2f5cb16b1d18f9/examples/web_scraper/browser.py#L12-L56))*\n\nWe register `open_page` as a pure function in Monty like this:\n\n```\nm = Monty(\n    extracted.code,\n    external_functions=['open_page', 'beautiful_soup', 'record_model_info'],\n    type_check=True,\n    type_check_stubs=stubs,\n)\n...\noutput = await run_monty_async(\n    m,\n    external_functions={\n        'open_page': browser.open_page,\n        'beautiful_soup': beautiful_soup,\n        'record_model_info': record_models.record_model_info,\n    },\n    print_callback=monty_print,\n)\n```\n\n*Full code [here](https://github.com/pydantic/monty/blob/2896169c44df8d0ff28f54442f2f5cb16b1d18f9/examples/web_scraper/main.py#L111-L131).*\n\nThen the `Page` type is similar to `Tag` above, but with a `__post_init__` method to get the playwright page object:\n\n```\n@dataclass\nclass Page:\n    \"\"\"A snapshot of a Playwright page.\"\"\"\n\n    url: str\n    title: str\n    html: str\n    id: int\n    _pw_page: PwPage = field(init=False)\n\n    def __post_init__(self):\n        self._pw_page = pw_pages[self.id]\n\n    async def go_to(\n        self,\n        url: str,\n        wait_until: Literal['commit', 'domcontentloaded', 'load', 'networkidle'] = 'networkidle',\n    ) -> None:\n        ...\n```\n\n*(This example is heavily truncated, full code [here](https://github.com/pydantic/monty/blob/2896169c44df8d0ff28f54442f2f5cb16b1d18f9/examples/web_scraper/external_functions.py#L29-L46))*\n\n**Here's a public trace from Logfire showing the full flow ([View full trace full screen](https://logfire-us.pydantic.dev/public-trace/50fe54a7-3fd1-4fc8-8ea9-4991c128a14a?spanId=4d3c9556957463cb)):**\n\nThat said, this approach of building custom dataclasses for every interface we need to expose to Monty is uglier than it should be. We're planning a new approach where you can inject any type into Monty, while keeping it safe by default.\n\n## \n\nWe're working hard on Monty, follow what we're doing on [GitHub](https://github.com/pydantic/monty) and please report any issues you find, especially:\n\n- **any security vulnerabilities** - I get the impression from issues that a number of people have already tried hard to break out of the Monty sandbox, and no one has succeeded, but real hardness comes with time and stress\n- **any Python behaviour you see LLMs wanting to use** - if LLMs want it, we'll add it\n\nMonty is early, but I'm as excited about it as anything we're doing. It seems like the obvious way to solve a problem many people have.", "url": "https://wpnews.pro/news/pydantic-monty-you-probably-don-t-need-a-full-sandbox", "canonical_source": "https://pydantic.dev/articles/pydantic-monty", "published_at": "2026-09-11 20:35:59+00:00", "updated_at": "2026-09-11 20:55:37.738752+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-products"], "entities": ["Pydantic", "Monty", "Samuel Colvin", "Anthropic", "Cloudflare", "HuggingFace", "Vercel", "Modal"], "alternates": {"html": "https://wpnews.pro/news/pydantic-monty-you-probably-don-t-need-a-full-sandbox", "markdown": "https://wpnews.pro/news/pydantic-monty-you-probably-don-t-need-a-full-sandbox.md", "text": "https://wpnews.pro/news/pydantic-monty-you-probably-don-t-need-a-full-sandbox.txt", "jsonld": "https://wpnews.pro/news/pydantic-monty-you-probably-don-t-need-a-full-sandbox.jsonld"}}