Skip to content

Usage Guide

Requirements

To use the container-backed Sandbox (third-party packages, full Python features), you need either Podman or Docker installed and running. The sandbox auto-detects which runtime is available. Monty-only usage (limited stdlib modules plus external functions) comes built-in.

How the sandbox works

Backyard provides two backends that both maintain persistent state across code executions:

Backend Execution Isolation Speed Supports
Monty Rust-based Python interpreter Resource limits (memory, timeout, recursion), opt-in filesystem mounting ~5ms per call limited set of stdlib modules (asyncio, dataclasses, datetime, json, math, os, pathlib, re, sys, typing), basic Python syntax (variables, functions, loops, etc.), any arbitrary Python function via external_functions
Container Out-of-process (Docker/Podman) Resource limits (CPU, memory, timeout), opt-in filesystem mounting ~500ms per call Any Python package, any feature

The Sandbox class starts with Monty by default and transparently converts to a container when the code requires features Monty cannot provide, automatically restoring state by replaying code-execution history.

Configuration

The sandbox configuration can be set at initialization and updated later if needed (e.g., increasing memory limits on a container sandbox to accommodate data analysis libraries; allowing write access; adding files to the workspace).

from backyard import Sandbox

sandbox = Sandbox(
    max_duration_sec=30.0,  # Max execution time per call
    max_memory_mb=256,  # Max memory (both backends respect this)
    max_cpus=2.0,  # Max CPUs (container only)
    enable_network=False,  # Network access (container only)
    enable_package_cache=True,  # Cache PyPI downloads (container only)
    tmpfs_size_mb=16,  # Size of in-memory tmpfs for /tmp (container only, 0 to disable)
    workspace=["/path/to/dir"],  # Directories and/or files to mount
    sandbox_type="auto",  # "auto" (default), "container", or "monty"
    readonly=False,  # If True, start in read-only mode.
)

Integrating as a tool for an AI agent

from backyard import Sandbox


class CodeExecutionTool:
    def __init__(self):
        self.sandbox = Sandbox(
            max_duration_sec=30.0,
            readonly=True,  # Agent must request write access
            track_file_changes=True,
        )

    def run_code(self, code: str) -> dict:
        result = self.sandbox.run(code)
        return {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "success": result.success,
            "result": result.result,
            "file_changes": result.file_changes,
            "duration_sec": result.duration_sec,
        }

    def run_with_packages(self, code: str, packages: list[str]) -> dict:
        result = self.sandbox.run(code, dependencies=packages)
        return {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "success": result.success,
        }

    def reset(self):
        self.sandbox.reset_session()

    def close(self):
        self.sandbox.close()

Option B: Provide external functions

Inject Python functions into the sandbox namespace:

from backyard import Sandbox


def fetch_data(url: str) -> str:
    import urllib.request

    with urllib.request.urlopen(url) as response:
        return response.read().decode()


# This works in the Monty sandbox, too! enable_network is not strictly required,
# since the external_functions execute in the host's environment, not the sandbox.
with Sandbox(enable_network=True) as sb:
    result = sb.run(
        "data = fetch_data('https://example.com/data.json'); len(data)",
        external_functions={"fetch_data": fetch_data},
    )
    print(result.result)

Option C: Provide input variables

Input variables can be passed to the sandbox as keyword arguments.

with Sandbox() as sb:
    result = sb.run("sum(x)", x=[1, 2, 3, 4, 5])
    print(result.result)  # 15

Setting up as an MCP server

You can expose Backyard as an MCP (Model Context Protocol) server for use with AI agents that support MCP tools. Create a simple wrapper:

# mcp_sandbox_server.py
import json
import sys
from backyard import Sandbox

sandbox = Sandbox(readonly=True)


def handle_request(request: dict) -> dict:
    action = request.get("action")

    if action == "execute":
        result = sandbox.run(
            request["code"],
            dependencies=request.get("dependencies"),
        )
        return {
            "stdout": result.stdout,
            "stderr": result.stderr,
            "success": result.success,
            "result": result.result,
        }

    elif action == "reset":
        sandbox.reset_session()
        return {"success": True}

    elif action == "grant_write":
        sandbox.grant_write_access()
        return {"success": True}

    elif action == "revoke_write":
        sandbox.revoke_write_access()
        return {"success": True}

    elif action == "ping":
        return {"success": sandbox.ping()}

    return {"success": False, "error": f"Unknown action: {action}"}


if __name__ == "__main__":
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        request = json.loads(line)
        response = handle_request(request)
        sys.stdout.write(json.dumps(response) + "\n")
        sys.stdout.flush()

Then configure your MCP client to use this server with stdio transport.

Workspace and file mounting

Mount directories and files into the sandbox:

from pathlib import Path

sandbox = Sandbox(workspace=[Path("/home/user/project")])

Files and directories are mounted at /sandbox/<name> inside the sandbox.

File change tracking

File changes are automatically tracked when workspace directories are configured:

sandbox = Sandbox(workspace=[Path("/home/user/project")])

result = sandbox.run("""
with open('new_file.txt', 'w') as f:
    f.write('created by sandbox')
""")

if result.file_changes and result.file_changes.has_changes:
    print(f"Added: {result.file_changes.added}")
    print(f"Modified: {result.file_changes.modified}")
    print(f"Deleted: {result.file_changes.deleted}")
    # Timestamps for each change:
    for change in result.file_changes.all_changes:
        print(f"  {change.change_type}: {change.path} at {change.timestamp}")

Ignored directories

The following directories are always hidden from the sandbox:

  • .git
  • .venv
  • node_modules
  • __pycache__
  • .pytest_cache
  • .mypy_cache
  • .ruff_cache

Additionally, patterns from .gitignore files in mounted workspace directories are applied.

Read-only mode

Read-only mode prevents the sandbox from modifying files:

# Start in read-only mode
sandbox = Sandbox(readonly=True)

# Grant write access mid-session
sandbox.grant_write_access()

# Revoke write access
sandbox.revoke_write_access()

In container mode, toggling read-only restarts the container and replays execution history.

Per-call resource overrides

Override the sandbox's default limits for a single execution:

result = sandbox.run(
    "compute_intensive_task()",
    max_duration_sec=60.0,  # Extend timeout for this call
    max_memory_mb=512,  # Increase memory for this call
)

Session management

# Reset state (clears namespaces, preserves container)
sandbox.reset_session()

# Full reset (closes container, clears everything)
sandbox.clear()

# Health check
if sandbox.ping():
    print("Sandbox is responsive")

Converting between backends

The automatic conversion from Monty to Container replays all previous successful code executions to restore state:

sandbox.run("x = 42")
sandbox.run("import numpy")  # Triggers conversion to container (in auto mode)
sandbox.run("x + 1")  # x=42 is preserved from Monty session

You can also control conversion behavior:

# Use Monty only (raises SandboxNotSupportedError on incompatible code)
sandbox = Sandbox(sandbox_type="monty")

# Use container from the start
sandbox = Sandbox(sandbox_type="container")

# Convert an existing session
sandbox.convert(SandboxType.CONTAINER)
sandbox.convert(SandboxType.MONTY, force=True)  # Clears incompatible history

Note: When sandbox_type="auto", the sandbox converts to a container if it encounters unsupported code, but only if no file changes have been made. If file changes exist, it raises a SandboxNotSupportedError suggesting a fresh session.

Tracking conversion progress

When converting to a container with long replay histories, you can monitor progress via progress_callback. The callback receives (current_step, total_steps, execution_result, phase):

from backyard import Sandbox
from backyard.sandbox import ReplayPhase


def on_replay_progress(
    current: int,
    total: int,
    execution: object | None,
    phase: ReplayPhase,
) -> None:
    if phase == ReplayPhase.BEFORE:
        print(f"Replaying step {current}/{total}...")
    elif phase == ReplayPhase.AFTER:
        status = "✓" if execution and getattr(execution, "success", False) else "✗"
        print(f"  {status} step {current} completed")
    elif phase == ReplayPhase.COMPLETE:
        print(f"Replay complete ({total} steps)")


sandbox = Sandbox(progress_callback=on_replay_progress)

Error handling

from backyard.errors import (
    SandboxError,
    SandboxTimeoutError,
    SandboxMemoryError,
    SandboxNotSupportedError,
    SandboxPermissionError,
)

try:
    result = sandbox.run(code)
    if result.failed:
        print(f"Execution failed:")
        print(f"  stdout: {result.stdout}")
        print(f"  stderr: {result.stderr}")
        print(f"  timed_out: {result.timed_out}")
        print(f"  memory_limit_hit: {result.memory_limit_hit}")
except SandboxNotSupportedError as e:
    print(f"Code incompatible with Monty: {e}")
    print("Either modify the code or use sandbox.convert(SandboxType.CONTAINER)")
except SandboxPermissionError as e:
    print(f"Write denied: {e}")
    print("Call sandbox.grant_write_access() first")