Code. Filesystem. Context. Tools.
What if agents had one tool to rule them all?
Parselbox is an embeddable Python sandbox where AI agents call tools as code — MCP servers, APIs, and shells become native Python objects. Disk-backed workspace, packages, and networking built in; a single process powered by Deno and Pyodide.
demo.mp4 #
Tip
Drop the Parselbox MCP alongside existing MCP server configurations. Agents instantly get a Python runtime, MCP tools as code, support for skills and a disk-backed workspace.
No containers, no VMs — just a single, lightweight Deno + Pyodide process (~160 MB). Deno permissions, memory caps, timeouts, network allowlists. Snapshot caching and crash recovery.
MCP servers, REST + OpenAPI, GraphQL, shell, functions and classes — all native Python objects. Stateful across calls. Pydantic auto-conversion. Credentials stay on the host.
Full CPython with js()
interop — use JS packages as native Python. require()
for npm, local TypeScript, and .wasm
modules. Virtual bash()
for shell. Auto-install packages on import.
require()
any .wasm
— library exports become Python methods, WASI programs become callable commands; drop one in bin/
to run it from bash()
too. In-process, inherits the sandbox's mounts and permissions, installs nothing on the host.
Append .task()
to any call — parallel fan-out with asyncio.gather
, check progress, tail logs, drive interactive sessions with send()
, await later.
Disk-backed workspace — host mounts (ro
/rw
), input files at /files/
, outputs persisted to real directories. New and modified files are detected and returned per call.
help()
, search()
, inspect()
, preview()
— agents discover only what they need, when they need it.
display()
renders HTML inline in the chat (MCP Apps), with Tailwind + daisyUI injected. Or serve a full app — built-in HTTP server with static files, live reload, file upload, and @api
routes that compose across tools.
Parselbox uses Deno for the secure sandbox runtime.
1. Install Deno
curl -fsSL https://deno.land/install.sh | sh
irm https://deno.land/install.ps1 | iex
2. Install Parselbox
pip install parselbox
Wire any tool into the sandbox — MCP servers, REST/GraphQL, shells, host objects — and the agent calls them as native Python, composing them with real control flow over a disk-backed workspace and both the Python and npm package ecosystems.
Example:
import asyncio
import os
from textwrap import dedent
from parselbox import Parselbox
from parselbox.bridge import HTTPBridge, ShellBridge
class Analytics:
def summarize(self, repos: list) -> dict:
"""Aggregate repo stats."""
stars = [r["stars"] for r in repos]
return {"count": len(repos), "avg_stars": round(sum(stars) / len(stars))}
config = {"mcpServers": {"playwright": {"command": "npx", "args": ["@playwright/mcp@latest"]}}}
async def main():
async with Parselbox(
mcp=config,
context={
"analytics": Analytics(),
"github": HTTPBridge(base_url="https://api.github.com", token=os.environ["GITHUB_TOKEN"]),
"sh": ShellBridge("bash"),
},
network=True,
allow_runtime_packages=True,
packages=["numpy", "npm:lodash"],
output_dir="./workspace",
) as sbx:
await sbx.execute_code("sbx.search('navigate|get')")
await sbx.execute_code(dedent("""
import re
playwright.browser_navigate(url="https://news.ycombinator.com")
text = playwright.browser_snapshot()
repos = re.findall(r'github\\.com/([\\w.-]+/[\\w.-]+)', text)[:5]
"""))
await sbx.execute_code(dedent("""
import asyncio
results = await asyncio.gather(*[github.get.task(f"/repos/{r}") for r in repos])
repo_data = [{"name": r["data"]["name"], "stars": r["data"]["stargazers_count"]}
for r in results if r.get("ok")]
analytics.summarize(repo_data)
"""))
result = await sbx.execute_code(dedent("""
import matplotlib.pyplot as plt
plt.barh([r["name"] for r in repo_data], [r["stars"] for r in repo_data])
plt.savefig("chart.png")
"""))
print(result.files) # ['chart.png']
image = sbx.read_file("chart.png")
await sbx.run_mcp()
asyncio.run(main())
The Parselbox CLI runs a standalone MCP server — every sandbox option is available as a flag.
Tip
The "loopback" trick:
- Add the Parselbox MCP alongside your existing MCP servers.
- Point
--mcp
at that same config file. - On startup, Parselbox connects to the other servers, exposes their tools inside the sandbox, and starts its own MCP server.
Don't worry — Parselbox detects and avoids connecting to itself. No infinite loops of doom.
Example:
{
"mcpServers": {
"github": {},
"linear": {},
"parselbox": {
"command": "uvx",
"args": ["parselbox", "--mcp", "/absolute/path/to/mcp.json"]
}
}
}
uvx parselbox --mcp mcp.json --transport http --port 9000
{
"mcpServers": {
"parselbox": {
"type": "http",
"url": "http://localhost:9000/mcp"
}
}
}
uvx parselbox \
--mcp ./mcp.json \
--transport http \
--host 0.0.0.0 \
--port 8080 \
--file hello.txt \
--mount ./datasets:/data:rw \
--output-dir ./outputs \
--packages pandas,matplotlib \
--package-dir ./cache \
--allow-runtime-packages \
--network \
--serve 3000 \
--memory 2048 \
--timeout 60 \
--env MY_API_KEY=...
python
import asyncio
from parselbox import Parselbox
from agents import Agent, Runner, function_tool
sandbox = Parselbox(
mcp={"mcpServers": {"playwright": {"command": "npx", "args": ["@playwright/mcp@latest"]}}},
output_dir="./outputs",
allow_runtime_packages=True,
)
agent = Agent(
name="Research Assistant",
model="gpt-5.5",
instructions=f"You are a world-class research assistant.\n\n{sandbox.get_prompt()}",
tools=[function_tool(sandbox.get_tool())],
)
async def main():
async with sandbox:
result = await Runner.run(
agent,
"Scrape Wikipedia's 'List of highest-grossing films' with the Playwright MCP. "
"Plot a bar chart of the top 10 and save it as ./plot.png",
max_turns=30,
)
print(result.final_output)
asyncio.run(main())
The context bridge exposes host Python objects inside the sandbox:
context
— functions and namespaces as callable tools. Execution s, runs on host, returns result.globals
— static values (strings, numbers, dicts) copied into the sandbox.mcp
— MCP server config (dict or path). Appears as callable namespaces inside sandbox.
Plain classes are auto-wrapped — every public method becomes a callable tool; methods starting with _
stay private:
from parselbox import Parselbox
class Calculator:
def add(self, a: float, b: float) -> float:
"""Add two numbers."""
return a + b
async with Parselbox(context={"calc": Calculator()}) as sbx:
await sbx.execute_code("calc.add(a=10, b=20)")
Subclass ** Bridge** for nested namespaces (auto-crawled); annotate a parameter with a Pydantic model and passed dicts convert to it automatically:
from parselbox import Parselbox
from parselbox.bridge import Bridge
from pydantic import BaseModel
class Coordinate(BaseModel):
x: float
y: float
z: float = 0.0
class Sensors(Bridge):
def temperature(self) -> float:
"""Read temperature in celsius."""
return 23.5
class Robot(Bridge):
def __init__(self):
self.sensors = Sensors()
def move(self, to: Coordinate) -> dict:
"""Move robot to a position."""
return {"position": [to.x, to.y, to.z], "status": "reached"}
async with Parselbox(context={"robot": Robot()}) as sbx:
await sbx.execute_code("robot.move(to={'x': 1, 'y': 2})")
await sbx.execute_code("robot.sensors.temperature()")
Parselbox ships bridges for REST, GraphQL, and shell:
from parselbox import Parselbox
from parselbox.bridge import HTTPBridge, GraphQLBridge, ShellBridge
api = HTTPBridge(
spec="https://petstore3.swagger.io/api/v3/openapi.json",
base_url="https://petstore3.swagger.io/api/v3",
)
gql = GraphQLBridge("https://countries.trevorblades.com/graphql")
sh = ShellBridge("ssh -T user@host")
mcp = {"mcpServers": {"deepwiki": {"type": "http", "url": "https://mcp.deepwiki.com/mcp"}}}
async with Parselbox(context={"api": api, "gql": gql, "sh": sh}, mcp=mcp, network=True) as sbx:
await sbx.execute_code('api.search("GET /pet/*")')
await sbx.execute_code('api.get("/pet/1")')
await sbx.execute_code('gql.graphql(query="{ continents { name } }")')
await sbx.execute_code('gql.graphql(query="{ languages { code name } }")')
await sbx.execute_code('term = sh.shell.task()')
await sbx.execute_code('term.send("df -h")')
await sbx.execute_code("sbx.search('ask|read')")
await sbx.execute_code("deepwiki.read_wiki_structure(repoName='pyodide/pyodide')")
await sbx.execute_code("deepwiki.ask_question(question='What is Pyodide?', repoName='pyodide/pyodide')")
Runnable:[bridges.py]
Every context and MCP call also has a .task()
form that runs on the host without blocking the sandbox — for parallel fan-out, long-running jobs, and interactive sessions:
job = sh.exec.task(command="ffmpeg -i in.mp4 out.mp4") # returns a task immediately
job.status() # TaskStatus(state, elapsed, message, logfile)
job.tail(5) # last lines of the task's live log
job.send("q") # message a running interactive process
await job.wait(timeout=120) # block until done — or just `await job`
job.cancel()
import asyncio
results = await asyncio.gather(*[api.get.task(f"/items/{i}") for i in range(5)])
MCP tools stream their progress and log notifications into the task's logfile. A custom Bridge
method emits the same way with self.log()
, and reads whatever the sandbox queued via send()
with self.recv()
:
from parselbox.bridge import Bridge
class Exporter(Bridge):
def run(self, rows: int) -> str:
for i in range(rows):
self.log(f"row {i}/{rows}") # appended to task.logfile → tail()
for msg in self.recv(): # messages queued by task.send()
self.log(f"got: {msg}")
return "done"
Interactive sessions — ShellBridge.shell()
keeps stdin open, so a task can drive a live process with send()
:
session = sh.shell.task() # a live shell — state persists within the session
session.send("x=21")
session.send("echo $((x * 2))")
import asyncio
await asyncio.sleep(1) # give it a beat
session.tail(1) # "42"
session.cancel()
An optional first command launches any REPL as the session — e.g. sh.shell.task("python3 -i")
.
Runnable:[tasks.py]
Parselbox runs on Pyodide's virtual filesystem, with the working directory, input files, mounts, and packages backed by real host directories — access gated by Deno's permission controls at startup.
| Method | Access Level | Description |
|---|---|---|
| files | ||
| Read / Write | Temp directory at /files/ . Input files copied here; server uploads stored here. |
|
| mounts | ||
| Configurable | Maps host directories to /mnt/{name} . Mode: ro (default) or rw . |
|
| output_dir | ||
| Read / Write | Maps working directory to a host directory to persist files. If not provided, defaults to a temp directory (wiped on close). |
Note
/workspace
is always backed by a real host directory —output_dir
(persistent) or an ephemeral temp dir (wiped on close) — enabling Deno streaming,resolvePath()
, andrequire()
for local modules.- Cross the boundary with
sandbox.read_file(path)
(str
for text,bytes
for binary) andsandbox.write_file(path, content)
; a persistentoutput_dir
is also readable directly. - Mounts with
target="skills"
are reported bysbx.info()
and discoverable viabash("ls /mnt/skills/")
.
Example:
from parselbox import Parselbox, Mount
async with Parselbox(
files=["data.csv"], # Read/write at /files/data.csv
mounts=[
Mount("./datasets", "/data", "ro"), # Read-only at /mnt/data
Mount("./workspace", "/work", "rw"), # Read/write at /mnt/work
],
output_dir="./outputs" # Sandbox files persisted here
) as sandbox:
sandbox.write_file("greeting.txt", "Hello from host!")
code = """
content = open('/files/data.csv').read() # input file
ref = open('/mnt/data/reference.json').read() # read-only mount
open('/mnt/work/processed.txt', 'w').write(content) # read/write mount
open('result.txt', 'w').write("Done!") # working dir -> output_dir
"""
result = await sandbox.execute_code(code)
print(result.files) # ['result.txt', 'greeting.txt']
sandbox.read_file("result.txt")
Reach the same files from a shell with bash()
:
bash("echo 'hello from bash' > note.txt && cat note.txt") # shell over the workspace
Runnable:[filesystem.py]·[bash.py]
Pyodide supports pure-Python packages and many C-extension packages, which must be pre-built for Pyodide — numpy, pandas, and more ship included.
from parselbox import Parselbox, Mount
Parselbox(packages=["numpy", "pandas", "npm:lodash"])
Parselbox(packages=["file:///host/wheels/pkg.whl"],
mounts=[Mount("./wheels", "wheels", "ro")])
Parselbox(packages=["https://example.com/pkg.whl"], network=True)
Parselbox(allow_runtime_packages=True)
Note
Package installs write straight to disk — a temp dir by default (wiped on exit). Set package_dir
to persist them across sessions, so the next boot is instant with no re-download.
After initial package , network is blocked by default. Access is configured with Deno's permission controls via --allow-net
/ --deny-net
. All HTTP from sandboxed code (requests, httpx, fetch) routes through Deno's fetch()
.
Parselbox(network=False)
Parselbox(network=["api.github.com:443"])
Parselbox(network=True)
Note
The CLI --network
flag is a boolean toggle only. Domain allowlists are available via the Python API.
Pyodide is not a security boundary — sandboxed code can read env vars via js('Deno.env.get("KEY")')
, so never pass real credentials in env
. Instead, run a credential-injecting proxy on the host and lock the sandbox to it:
async with Parselbox(
network=["127.0.0.1:8900"], # sandbox can ONLY reach the proxy
env={
"OPENAI_BASE_URL": "http://127.0.0.1:8900/v1",
"OPENAI_API_KEY": "phantom-token", # harmless; the real key lives on the proxy
},
) as sbx:
await sbx.execute_code("import openai; openai.OpenAI().chat.completions.create(...)")
Most SDKs take a base_url
override. For SDK-agnostic interception, set HTTP_PROXY
/HTTPS_PROXY
/DENO_CERT
instead and route everything through a MITM proxy — Deno's fetch()
honours them at the process level.
Runnable:[basics.py]
Parselbox runs Python inside Deno's V8 engine via Pyodide, so Python and JavaScript share the same process memory — interop is seamless.
js("return data.map(x => x * 2)", data=[1, 2, 3]) # [2, 4, 6]
js("return items.filter(fn)", items=[1,2,3,4,5], fn=lambda x, *_: x > 3) # [4, 5]
js("return crypto.randomUUID()")
Each js()
call runs in a fresh, stateless scope. Python callables are auto-proxied and cleaned up after the call. Binary converts too — Uint8Array
/ArrayBuffer
results become Python bytes
, and bytes
arguments become Uint8Array
s.
lodash = require("lodash")
lodash.chunk([1, 2, 3, 4], 2) # [[1, 2], [3, 4]]
lodash.sortBy(data, lambda x, *_: x["age"])
js("return lodash.invert({a: 1, b: 2})")
require("./math_utils.ts").fibonacci(10)
dayjs = require("dayjs")
dayjs("2026-06-15").add(30, "day").format("YYYY-MM-DD") # "2026-07-15"
color = require("color")
color("red").darken(0.5).hex() # "#800000"
lodash(data).filter(lambda x, *_: x["pay"] > 100).sortBy(lambda x, *_: -x["pay"]).value()
For files too large to fit in memory, use Deno streams via resolvePath()
:
js("""
const path = resolvePath("sample.txt");
const info = await Deno.stat(path);
return { size: info.size, isFile: info.isFile };
""")
Python callbacks work inside streaming pipelines — Deno reads, JS parses, Python classifies each line.
open("helpers.py", "w").write("def double(x): return x * 2")
from helpers import double
double(21) # 42
open("transform.ts", "w").write("export function upper(s: string) { return s.toUpperCase(); }")
require("./transform.ts").upper("hello") # "HELLO"
You can even compile a language to WebAssembly in-sandbox, then require()
the output.
A pure-JavaScript bash (just-bash) over the same workspace. Pipes and coreutils work, and curl
is backed by fetch
. Each call is isolated (cd
/export
don't persist); filesystem changes do.
bash("echo hello > note.txt && cat note.txt | tr a-z A-Z") # "HELLO"
bash("grep -rn hello . | wc -l")
bash("curl -s https://api.github.com/zen") # network rules still apply
Runnable:[javascript.py]·[bash.py]
Pyodide can only load packages built for it — so pandoc
, ruby
or shellcheck
are out of reach, and there is no apt-get
in a single-process sandbox. Parselbox closes that gap with WASI: any program compiled to WebAssembly becomes a tool, with no host install.
A missing capability is just a file.
require()
inspects the module and picks the right shape:
require("./fib.wasm").fib(20) # 6765
pandoc = require("./pandoc.wasm")
r = pandoc(["-f", "markdown", "-t", "html5"], stdin="# Report")
r["stdout"].decode() # '<h1 id="report">Report</h1>'
A command returns {"exit": int, "stdout": bytes, "stderr": str, "missing": [...]}
— missing
lists any syscalls the binary asked for that aren't implemented, so gaps surface as data rather than a crash.
Important
Emscripten builds are not WASI builds. Much of npm's "wasm" (sql.js
, ffmpeg.wasm
, tesseract.js
) is compiled with Emscripten and needs its own JavaScript glue — import those as npm packages (require("sql.js")
), not as bare .wasm
files. Both routes work; require()
tells you which one a binary needs.
run(args=None, stdin="", env=None, preopens=None, argv0=None)
—stdin
str
orbytes
;always comes back asstdout
bytes
.— grant extra guest directories, e.g.preopens
preopens={"/usr": "vendor/usr"}
for a binary that expects its own tree.— some binaries dispatch on their program name (lld becomesargv0
wasm-ld
busybox-style).
A WASI command binary (a .wasm
exporting _start
) in a mount's bin/
directory becomes a shell command, usable alongside bash()
's JavaScript coreutils. Binaries are discovered per call, so a tool written mid-session works immediately.
open("bin/pandoc.wasm", "wb").write(pandoc_bytes)
bash("pandoc -f markdown -t plain notes.md | head -3 | tr a-z A-Z")
Mount a bin/
folder read-only to ship a fixed toolset the agent can use but not modify — nothing installed on the host — or have it fetch a .wasm
into bin/
at runtime, which works even when the sandbox's network is restricted to a single allowlisted host.
You can even build one from source in-process — fetch a WASI clang + wasm-ld
into bin/
, compile C to .wasm
, then require()
the result. No host toolchain, nothing installed.
Note
- Auto-detected as WASI
preview1
orwasi_unstable
(preview0). Not supported: sockets, real sleeps, preview2 components. - Compiled modules are cached per path (invalidated on rebuild), so a 50MB binary compiles once per session.
Runnable:[pandoc.py]— fetch a WASI binary ·[compile_c.py]— compile C → wasm in-sandbox
The sbx
toolkit lets agents discover capabilities on demand instead of everything into context up front. Available as sbx.*
inside the sandbox.
| Function | Description |
|---|---|
sbx.help() |
|
| Returns a full guide to using the sandbox. | |
sbx.info() |
|
| Get sandbox environment info — context, packages, network, mounts, serve etc. | |
sbx.search(pattern) |
|
| Search tools across all namespaces by name, description, or parameter. | |
sbx.inspect(tools) |
|
| Get detailed schemas and documentation for tools. | |
sbx.preview(data) |
|
| Summarize large or nested data structures — preserves keys, truncates content. |
The sandbox also exposes a help()
builtin for per-object introspection:
help()
help(robot)
help(robot.move)
help(len)
Example:
sbx.info()
sbx.search("repo|query")
sbx.inspect(["github.search_repositories", "db.query", "robot.move"])
import asyncio
results = await asyncio.gather(*[api.fetch.task(id=i) for i in ids])
sbx.preview(results)
Runnable:[toolkit.py]
Agents can surface results two ways: inline in the conversation with display()
, or as a full web app with serve
.
Any HTML an agent passes to display()
renders as a widget beneath its result, in hosts that support MCP Apps.
await sbx.execute_code("""
display("<h1>Q3 Revenue</h1><p class='text-lg'>Up <b>12%</b> to $4.1M</p>")
""")
Tailwind and daisyUI are injected automatically, so plain markup is styled without a build step, and pbx.call("/api/route", body)
inside the HTML reaches @api
handlers when serve
is on. display()
also accepts a path to an HTML file in the workspace. One view per execution — the last call wins.
On by default. run_mcp(ui=False)
turns it off, which stops advertising display()
to the agent and drops the renderer from the tool. The rendered HTML is always on result.view
regardless:
result = await sbx.execute_code('display("<b>done</b>")')
result.view # full HTML document, or None if display() wasn't called
The serve
option starts a Deno HTTP server inside the sandbox — agents build full web apps on the fly.
sandbox = Parselbox(serve=3000)
uvx parselbox --serve 3000
Static Files: Any files written to the Pyodide working directory are automatically served:
open("index.html", "w").write("<h1>Hello World</h1>")
open("style.css", "w").write("h1 { color: blue; }")
Served at their own paths, with /
resolving to index.html
; uploaded and input files live under /files/*
.
API Handlers: Define endpoints using FastAPI-style decorators:
@api.get("/items")
def list_items(params):
limit = int(params.get("limit", 10))
return items[:limit]
@api.post("/items")
def create_item(body):
return {"id": len(items) + 1, "name": body["name"]}
Routes are prefixed with /api/
automatically. Verbs: @api.get/post/put/patch/delete
.
Handlers can call MCP tools, context functions, and any sandbox code:
@api.get("/dashboard")
async def dashboard(params):
import asyncio
sensors, orders = await asyncio.gather(
robot.sensors.temperature.task(),
store.get.task("/orders", params={"limit": 5}),
)
return {"temperature": sensors, "recent_orders": orders}
Built-in Endpoints:
| Endpoint | Method | Description |
|---|---|---|
/_upload |
||
| POST | File upload (multipart form data) | |
/_live |
||
| GET | SSE stream — connected browsers refresh when static files change (on by default) | |
/_routes |
||
| GET | List registered API handlers |
curl -F "file=@photo.png" http://localhost:3000/_upload
Runnable:[display.py]·[serve.py]
Hooks intercept sandbox lifecycle events — log executions, approve tool calls, enforce policies. Pass them via the hooks
parameter.
from parselbox import Parselbox, Callback, ExecutionResult
from parselbox.hooks import Hook
class AuditHook(Hook):
async def pre_execute(self, code: str):
print(f"Executing: {code[:80]}...")
async def post_execute(self, result: ExecutionResult):
print(f"Result: {result.output}")
async def pre_tool_call(self, callback: Callback):
if "drop" in str(callback.kwargs).lower():
raise PermissionError("DROP statements are blocked")
async def post_tool_call(self, callback: Callback, result):
print(f"Tool {callback.name} returned")
async with Parselbox(
context={"db": db},
hooks=[AuditHook()],
) as sbx:
await sbx.execute_code("db.query(sql='SELECT 1')")
** ElicitHook** — a built-in hook that uses MCP elicitation for human-in-the-loop approval. Enable via
--elicit
(CLI) or run_mcp(elicit=True)
(API). Only fires if the MCP client advertises elicitation capability — otherwise it's a no-op.
uvx parselbox --mcp mcp.json --elicit
await sandbox.run_mcp(elicit=True)
| Hook | Trigger | Use Cases |
|---|---|---|
pre_execute |
||
| Before code runs | Logging, policy checks, code sanitization | |
post_execute |
||
| After code completes | Audit trails, result validation | |
pre_tool_call |
||
| Before a context/MCP call | Approval gates, rate limiting, blocking | |
post_tool_call |
||
| After a context/MCP call returns | Logging, result transformation |
Runnable:[hooks.py]
Parselbox
has the following configuration options:
from parselbox import Parselbox, Mount
sandbox = Parselbox(
context=dict(db=db, notify=send_alert), # Proxied functions and namespaces
globals=dict(name="hi", threshold=0.5), # Static values copied into sandbox
files=["./input.txt"], # Read/write files at /files/
mounts=[
Mount("./datasets", "/data", "ro"), # Read-only mount
Mount("./workspace", "/work", "rw"), # Read/write mount
],
output_dir="./outputs", # Persist sandbox files
packages=["numpy", "npm:lodash"], # Install on startup (Python + npm)
package_dir="./cache", # Persist package cache across sessions
allow_runtime_packages=True, # Auto-install from imports (default: False)
network=True, # True, False, or ["domain:port", ...] (API only)
mcp="./mcp.json", # Connect MCP servers (path or dict)
serve=8080, # Enable web server on port
memory=2048, # WASM memory limit in MB (default: 2048)
timeout=60, # Execution timeout in seconds (default: 60, 0 disables)
hooks=[AuditHook()], # Lifecycle hooks
env={ # Custom env vars (available in Python os.environ)
"OPENAI_BASE_URL": "http://proxy/v1", # SDK base_url overrides for reverse proxy
"OPENAI_API_KEY": "phantom", # Phantom tokens (real keys on proxy)
"HTTP_PROXY": "http://proxy:8080", # Deno-level proxy (filtered from os.environ)
"DENO_CERT": "/path/to/ca.pem", # Custom CA for MITM proxy
},
)
Parselbox runs agent code in one Deno process with Pyodide (CPython in WebAssembly) — no containers, no VMs. The permission-jailed sandbox works in an isolated temp workspace, with no network and no host access beyond the mounts you grant; the host holds the credentials. Every tool call is a round-trip between them:
1. exec HOST ──▶ SANDBOX your code runs, permission-jailed
2. callback HOST ◀── SANDBOX code calls a tool as native Python
3. result HOST ──▶ SANDBOX host runs it with the real credentials
Tools look like native Python inside the sandbox, but they execute on the host — so credentials never enter the sandbox.
Parselbox's boundary is Deno's permission system — the sandbox starts with nothing and gets only what you configure.
Filesystem— isolated temp workspace (wiped on exit); read/write only to paths you pass (files
,mounts
asro
/rw
,output_dir
). Package-cache writes lock after startup unlessallow_runtime_packages=True
.Network— off by default (revoked before your code runs). Opt in withnetwork=True
, an allowlistnetwork=["host:port", ...]
, orallow_runtime_packages=True
(package domains only). For authenticated APIs, front it with a proxy — seeProxy & Credential Injection.Compiled tools (WASI)— no sockets, so a binary has no network of its own; it sees only the mounts you grant (ro
enforced by Deno), and a runaway is killed by the execution timeout.Resource limits— WASM memory capped per instance at the V8 level (default 2048 MB), JS heap capped, per-execution timeout (default 60s →KeyboardInterrupt
), auto-reconnect if the Deno process dies.Context bridge— only the objects you pass are reachable, and only their public methods; MCP servers expose their full tool set.
Code execution with MCP(Anthropic)Code Mode(Cloudflare)smolagents(Hugging Face)Deno + Pyodide Sandbox(Simon Willison)