How to Define and Register Custom External Functions in Monty: A Complete Guide

To define and register custom external functions in Monty, declare the function names when constructing the Monty instance, then supply the Python implementations via the external_functions parameter when calling run(), start(), or create().

Monty is a Python sandbox execution environment developed by Pydantic that isolates interpreted code from the host runtime. When you need to extend sandboxed code with host-side capabilities—such as database access, HTTP requests, or filesystem operations—you must define and register custom external functions that bridge the VM boundary safely.

Understanding External Functions in Monty

Monty executes Python code in a sandbox and deliberately blocks direct access to the host runtime. When interpreted code calls a function whose name is listed in the external functions declaration, the VM pauses, yields a FunctionCall progress object, and requests the host to supply the actual implementation.

This two-stage process maintains security: the sandbox knows which names it may call, but the host decides what those names actually do at execution time.

Declaring External Functions

The first step is declaring which function names the sandboxed code is permitted to call. Pass a list of strings to the external_functions parameter when creating a Monty instance:

import pydantic_monty

m = pydantic_monty.Monty(
    code='my_func(42)', 
    external_functions=['my_func']   # Names that may be called

)

This declaration establishes the security boundary. Only functions listed here can trigger the external function bridge; any other calls remain internal to the sandbox.

Registering Python Implementations

After declaration, you must register the actual Python callables when executing the code. Pass a dictionary mapping the declared names to functions via the external_functions parameter of run(), start(), or create():

result = m.run(
    external_functions={'my_func': my_impl}
)

The dictionary keys must exactly match the declared names. The values can be any callable accepting *args and **kwargs.

Simple Synchronous Functions

For basic use cases, define a standard Python function:

def greet(name: str) -> str:
    return f"Hello, {name}!"

m = pydantic_monty.Monty(
    code='greet("World")',
    external_functions=['greet']
)

result = m.run(external_functions={'greet': greet})
print(result)  # → "Hello, World!"

Handling Positional and Keyword Arguments

The external function bridge automatically converts Monty arguments to Python objects. Your implementation receives both positional and keyword arguments:

def combine(*args, **kwargs):
    # args = (1, 2), kwargs = {'sep': '-'}

    separator = kwargs.get('sep', ',')
    return separator.join(map(str, args))

m = pydantic_monty.Monty(
    code='combine(1, 2, sep="-")',
    external_functions=['combine']
)

print(m.run(external_functions={'combine': combine}))  # → "1-2"

Grouping Functions with Classes

For complex applications, organize related external functions using classes. This pattern appears in examples/sql_playground/external_functions.py:

from dataclasses import dataclass
from pathlib import PurePosixPath
from pydantic_monty import OSAccess

@dataclass
class ExternalFunctions:
    fs: OSAccess  # Monty-provided filesystem helper

    
    async def query_csv(self, filepath: PurePosixPath, sql: str, parameters: dict | None = None):
        """Execute SQL on a CSV stored in Monty's virtual filesystem."""
        content = self.fs.path_read_bytes(filepath)
        
        import tempfile, duckdb
        with tempfile.NamedTemporaryFile(mode='wb', suffix='.csv') as tmp:
            tmp.write(content)
            tmp.flush()
            conn = duckdb.connect(':memory:')
            conn.read_csv(tmp.name)
            result = conn.execute(sql, parameters)
            cols = [d[0] for d in result.description]
            rows = result.fetchall()
        return [dict(zip(cols, r)) for r in rows]

# Usage

extern = ExternalFunctions(fs=my_os_access)

m = pydantic_monty.Monty(
    code='query_csv("/data/file.csv", "SELECT * FROM data")',
    external_functions=['query_csv']
)

result = m.run(external_functions={'query_csv': extern.query_csv})

Async External Functions with Iterative Execution

For asynchronous operations or long-running tasks, use the iterative API to pause execution and resume later:

import asyncio
import pydantic_monty

async def async_fetch(url: str):
    await asyncio.sleep(0.1)  # Simulate I/O

    return f"data from {url}"

m = pydantic_monty.Monty(
    code='await fetch("https://example.com")',
    external_functions=['fetch']
)

# Start execution - pauses at external call and returns a snapshot

snapshot = m.start()

# Resolve the future and resume

final = snapshot.resume(return_value=async_fetch)
print(final)  # → "data from https://example.com"

How the External Function Bridge Works

Understanding the internal mechanics helps debug complex integrations. According to the pydantic/monty source code, the bridge operates across Rust and Python layers:

  1. VM Detection: When the Rust VM in monty/src/value.rs encounters a call to a declared external function, it creates a RunProgress::FunctionCall and pauses execution.

  2. Registry Creation: In crates/monty-python/src/monty_cls.rs, the run_impl method constructs an ExternalFunctionRegistry from the Python dictionary you provide (lines 69-89).

  3. Argument Conversion: The registry in crates/monty-python/src/external.rs (lines 19-27) converts Monty values to Python objects using monty_to_py, invokes your callable, then converts the result back via py_to_monty.

  4. GIL Management: The VM releases the Python GIL while running sandboxed code, re-acquiring it only for the actual external function call. This preserves isolation while allowing CPU-bound Monty code to run concurrently with host-side logic.

Error Handling and Exception Propagation

When external functions raise exceptions, Monty converts them to runtime errors inside the sandbox. As implemented in crates/monty-python/src/external.rs (line 44), the exc_py_to_monty function wraps Python exceptions and re-raises them as MontyRuntimeError within the interpreted code, preserving the original exception type and message.

This allows sandboxed code to catch specific exceptions using standard Python error handling:

def failing_function():
    raise ValueError("Something went wrong")

m = pydantic_monty.Monty(
    code='''
try:
    failing_function()
except ValueError as e:
    result = f"Caught: {e}"
''',
    external_functions=['failing_function']
)

result = m.run(external_functions={'failing_function': failing_function})

# result contains "Caught: Something went wrong"

Summary

  • Declare first: List external function names in the external_functions parameter when creating a Monty instance to establish the security boundary.
  • Register at runtime: Supply implementations via the external_functions dictionary in run(), start(), or create(), mapping declared names to Python callables.
  • Flexible signatures: External functions accept *args and **kwargs with automatic type conversion between Monty and Python values via monty_to_py and py_to_monty.
  • Async support: Use the iterative API (start() and resume()) to handle asynchronous external functions or long-running operations.
  • Error propagation: Python exceptions convert to MontyRuntimeError inside the sandbox while preserving original error details, allowing standard exception handling in interpreted code.

Frequently Asked Questions

Can I register external functions dynamically after creating the Monty instance?

No, you must declare the allowed external function names when constructing the Monty instance via the external_functions parameter. However, you can choose which Python callables to bind to those names at execution time when calling run() or start(). This two-stage approach maintains the security boundary while allowing flexible implementation swapping between runs.

What types can be passed between Monty and external functions?

Monty automatically converts supported types between the sandbox and Python via monty_to_py and py_to_monty functions in crates/monty-python/src/external.rs. Supported types include primitives (int, float, str, bool), collections (list, dict, tuple), and dataclasses. Complex objects that cannot be serialized across the boundary will raise conversion errors.

How do I handle exceptions raised by external functions?

When an external function raises a Python exception, Monty catches it in crates/monty-python/src/external.rs (line 44) using exc_py_to_monty and re-raises it as a MontyRuntimeError inside the sandboxed code. The original exception type and message are preserved, allowing sandboxed code to catch specific exception types using standard Python try/except blocks.

Can external functions access the Monty virtual filesystem?

Yes, external functions can interact with Monty's virtual filesystem by accepting OSAccess objects as constructor arguments or parameters. As demonstrated in examples/sql_playground/external_functions.py, you can inject filesystem helpers into class-based external functions, allowing sandboxed code to read virtual files while the external function processes them using host-side libraries like DuckDB or Pandas.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →