cd /news/ai-tools/show-hn-parselbox-an-embeddable-pyth… · home topics ai-tools article
[ARTICLE · art-106095] src=github.com ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Show HN: Parselbox – an embeddable Python sandbox for AI agents

Parselbox, an embeddable Python sandbox for AI agents, launched on Hacker News, enabling agents to call MCP servers, APIs, and shells as native Python objects in a single Deno and Pyodide process (~160 MB). The tool provides a disk-backed workspace, built-in networking, package installation, and supports parallel task execution, with credentials staying on the host.

read20 min views1 publishedAug 21, 2026
Show HN: Parselbox – an embeddable Python sandbox for AI agents
Image: Michielbdejong (auto-discovered)

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 sessionsShellBridge.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)

── more in #ai-tools 4 stories · sorted by recency
── more on @parselbox 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-hn-parselbox-an…] indexed:0 read:20min 2026-08-21 ·