LLMs are better at writing Python than crafting JSON tool calls. But running LLM-generated code means either spinning up containers, paying for sandboxing services, or gambling with exec(). Littrs takes a different approach: a Python sandbox that embeds directly into your Rust or Python application as a library. No containers, no network calls, no infrastructure β just pip install or cargo add and go.
You register functions as tools, hand the sandbox some LLM-generated code, and get back a result. The sandbox compiles Python to bytecode and runs it on a stack-based VM with zero ambient capabilities β no filesystem, no network, no env vars. The only way sandboxed code can reach the outside world is through tools you explicitly provide.
- Tool registration β
@sandbox.toolin Python,#[tool]in Rust. Inject variables withsandbox["x"] = val, run code withsandbox(code) - Resource limits β cap bytecode instructions and recursion depth per call, enforced at the VM level and uncatchable by
try/except - Stdout capture β
print()output collected and returned separately from the result - Auto-generated tool docs β
describe()produces Python-style signatures and docstrings, ready to paste into a system prompt - Built-in modules β
json,math, andtypingavailable out of the box withSandbox(builtins=True)/Sandbox::with_builtins(). Register custom modules with.module() - File mounting β mount host files into the sandbox with read-only or read-write access. Sandbox code uses
open()to read/write; writes persist back to the host.sandbox.files()lets you inspect current writable file contents - WASM isolation β optional stronger sandboxing via an embedded wasmtime guest module with memory and fuel limits
- Fast startup β no interpreter boot, no runtime to load. Create a sandbox, register tools, run code
Littrs implements enough Python for an LLM to call tools, process results, handle errors, and return values. It does not support third-party packages, classes, closures, async/await, finally, or match β see the ROADMAP for what's planned and the full list of supported features.
cargo add littrsuv pip install littrsLittrs can be called from Rust or Python. See the ROADMAP for planned features.
from littrs import Sandbox
sandbox = Sandbox()
@sandbox.tool
def get_weather(city: str, units: str = "celsius") -> dict:
"""Get current weather for a city."""
return {"city": city, "temp": 22, "units": units}
result = sandbox("get_weather('London')")
# result == {"city": "London", "temp": 22, "units": "celsius"}The @sandbox.tool decorator registers your function with its full signature β the LLM code calls it like a normal Python function. The sandbox is also callable: sandbox(code) is shorthand for sandbox.run(code).
Variables persist across calls, and you can inject values directly:
sandbox["user_id"] = 42
sandbox("name = get_weather('London')['city']")
sandbox("name") # "London"sandbox.limit(max_instructions=10_000, max_recursion_depth=50)
try:
sandbox.run("while True: pass")
except RuntimeError as e:
print(e) # "Instruction limit exceeded (limit: 10000)"Resource limit errors are uncatchable β try/except in the sandbox code cannot suppress them.
capture() returns both the result and everything that was print()-ed:
result, printed = sandbox.capture("""
for i in range(5):
print(i)
"done"
""")
# result == "done"
# printed == ["0", "1", "2", "3", "4"]describe() auto-generates Python-style signatures and docstrings from registered tools, ready to embed in a system prompt:
print(sandbox.describe())
# def get_weather(city: str, units: str = 'celsius') -> dict:
# """Get current weather for a city."""If you need to bypass the decorator (e.g. registering a function that takes raw positional args):
def fetch_data(args):
return {"id": args[0], "name": "Example"}
sandbox.register("fetch_data", fetch_data)Create a sandbox with builtins=True to enable json, math, and typing modules:
sandbox = Sandbox(builtins=True)
result = sandbox("""
import json
data = json.loads('{"name": "Alice", "score": 95}')
data["score"]
""")
# result == 95from ... import works too:
sandbox("""
from math import sqrt, pi
sqrt(pi)
""")Register custom modules with .module():
sandbox.module("config", {"version": "1.0", "debug": False})
sandbox("import config; config.version") # "1.0"Mount host files into the sandbox so LLM-generated code can read input and write output without full filesystem access:
sandbox.mount("data.json", "./data/input.json") # read-only (default)
sandbox.mount("output.txt", "./output/result.txt", writable=True) # read-write
result = sandbox("""
f = open("data.json")
data = f.read()
f.close()
f = open("output.txt", "w")
f.write("processed: " + data)
f.close()
""")
# Inspect written files from the host
sandbox.files() # {"output.txt": "processed: ..."}Unmounted paths raise FileNotFoundError; writing to read-only mounts raises PermissionError. Both are catchable with try/except inside the sandbox.
For stronger isolation, Littrs can run the interpreter inside a WebAssembly guest module with memory isolation and fuel-based computation limits:
from littrs import WasmSandbox, WasmSandboxConfig
config = WasmSandboxConfig().with_fuel(1_000_000).with_max_memory(32 * 1024 * 1024)
sandbox = WasmSandbox(config)
result = sandbox.run("sum(range(100))")
assert result == 4950The #[tool] macro is the easiest way to register tools. Write a normal function with doc comments, and the macro generates everything needed for registration and LLM documentation:
use littrs::Sandbox;
use littrs_macros::tool;
/// Get current weather for a city.
///
/// Args:
/// city: The city name
/// units: Temperature units (C or F)
#[tool]
fn get_weather(city: String, units: Option<String>) -> String {
format!("{}: 22Β°{}", city, units.unwrap_or("C".into()))
}
let mut sandbox = Sandbox::new();
sandbox.add(get_weather::Tool);
let result = sandbox.run(r#"get_weather("London")"#).unwrap();The #[tool] macro handles type conversion from PyValue automatically. sandbox.add() registers the tool with its full metadata.
Variables persist across run() calls:
sandbox.run("x = 10").unwrap();
sandbox.run("y = 20").unwrap();
let result = sandbox.run("x + y").unwrap();
assert_eq!(result, PyValue::Int(30));use littrs::{Sandbox, Limits};
let mut sandbox = Sandbox::new();
sandbox.limit(Limits {
max_instructions: Some(10_000),
max_recursion_depth: Some(50),
});
let err = sandbox.run("while True: pass").unwrap_err();
assert!(err.to_string().contains("Instruction limit"));Resource limit errors are uncatchable β try/except in the sandbox code cannot suppress them. This is by design: the host must always be able to regain control.
describe() auto-generates Python-style docs for all registered tools, suitable for embedding in an LLM system prompt:
let docs = sandbox.describe();
// def get_weather(city: str, units?: str) -> str:
// """Get current weather for a city."""let mut sandbox = Sandbox::new();
let output = sandbox.capture(r#"
for i in range(5):
print(i)
"done"
"#).unwrap();
assert_eq!(output.output, vec!["0", "1", "2", "3", "4"]);
assert_eq!(output.value, PyValue::Str("done".to_string()));For cases where the #[tool] macro isn't suitable, you can register closures directly:
use littrs::{Sandbox, PyValue};
let mut sandbox = Sandbox::new();
sandbox.register_fn("fetch_data", |args| {
let id = args[0].as_int().unwrap_or(0);
PyValue::Dict(vec![
(PyValue::Str("id".to_string()), PyValue::Int(id)),
(PyValue::Str("name".to_string()), PyValue::Str("Example".to_string())),
])
});Use Sandbox::with_builtins() to enable json, math, and typing modules:
use littrs::{Sandbox, PyValue};
let mut sandbox = Sandbox::with_builtins();
let result = sandbox.run(r#"
import json
data = json.loads('{"name": "Alice", "score": 95}')
data["score"]
"#).unwrap();
assert_eq!(result, PyValue::Int(95));Register custom modules with .module():
use littrs::{Sandbox, PyValue};
let mut sandbox = Sandbox::new();
sandbox.module("config", |m| {
m.constant("version", PyValue::Str("1.0".into()));
m.function("get_flag", |_args| PyValue::Bool(true));
});
let result = sandbox.run("import config; config.version").unwrap();
assert_eq!(result, PyValue::Str("1.0".into()));Mount host files into the sandbox for controlled file I/O:
use littrs::{Sandbox, PyValue};
let mut sandbox = Sandbox::new();
sandbox.mount("data.json", "./data/input.json", false); // read-only
sandbox.mount("output.txt", "./output/result.txt", true); // read-write
sandbox.run(r#"
f = open("data.json")
content = f.read()
f.close()
f = open("output.txt", "w")
f.write("processed")
f.close()
"#).unwrap();
let files = sandbox.files(); // {"output.txt": "processed"}Unmounted paths raise FileNotFoundError; writing to read-only mounts raises PermissionError. Both are catchable inside the sandbox with try/except.
Littrs is designed for one specific use case: running code written by AI agents safely and cheaply. It trades language completeness for simplicity, speed, embeddability, and zero infrastructure requirements.
| Tech | Security | Start latency | Embeddable | Resource limits | Tool registration | WASM isolation | Setup |
|---|---|---|---|---|---|---|---|
| Littrs | strict (no ambient access) | ~1ms | Rust, Python | instruction + recursion caps | built-in | built-in | cargo add / pip install |
| Docker | good (container isolation) | ~200ms | no (separate process) | cgroups | roll your own | no | daemon + images |
| Pyodide | poor (JS sandbox leaks) | ~2800ms | JS only | hard to enforce | roll your own | host-level only | WASM runtime + 12MB |
| Monty | strict | <0.1ms | Rust, Python, JS | memory + time + allocations | built-in | no | pip install |
| Sandboxing services | strict (managed) | ~1000ms | no (API call) | service-managed | API-based | service-managed | API keys + network |
exec() / subprocess |
none | ~0.1ms | Python only | none | none | no | none |
Comparison table adapted from Monty.
Why Littrs over Docker/services? Zero infrastructure. No daemon, no containers, no network calls, no API keys. Just a library you import. Ideal for edge deployments, embedded systems, or anywhere you can't run Docker.
Why Littrs over exec()? Security. exec() gives LLM-generated code full access to your filesystem, network, and environment. Littrs gives it access to nothing except the tools you explicitly register.
Why Littrs over Pyodide? Startup speed and server-side safety. Pyodide takes seconds to cold-start and wasn't designed for server-side isolation β Python code can escape into the JS runtime.
Why Littrs over Monty? Developer experience. Littrs provides a cleaner API β @sandbox.tool to register a function, sandbox(code) to run it, sandbox["x"] = val to inject variables. No boilerplate, no separate input/output declarations, no configuration objects. It also includes built-in WASM isolation for stronger sandboxing when you need it.
If you use Littrs in your research, please cite it as:
@software{littrs,
title = {Littrs: A Minimal, Secure Python Sandbox for AI Agents},
author = {Chonkie Inc.},
url = {https://github.com/chonkie-inc/littrs},
license = {Apache-2.0},
year = {2025-2026}
}