{"slug": "show-hn-parselbox-an-embeddable-python-sandbox-for-ai-agents", "title": "Show HN: Parselbox – an embeddable Python sandbox for AI agents", "summary": "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.", "body_md": "Code. Filesystem. Context. Tools.\n\nWhat if agents had one tool to rule them all?\n\nParselbox 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](https://deno.com/) and [Pyodide](https://pyodide.org/en/stable/).\n\n## demo.mp4\n\nTip\n\nDrop the [Parselbox MCP](#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.\n\nNo containers, no VMs — just a single, lightweight Deno + Pyodide process (~160 MB). Deno permissions, memory caps, timeouts, network allowlists. Snapshot caching and crash recovery.\n\nMCP servers, REST + OpenAPI, GraphQL, shell, functions and classes — all native Python objects. Stateful across calls. Pydantic auto-conversion. Credentials stay on the host.\n\nFull CPython with `js()`\n\ninterop — use JS packages as native Python. `require()`\n\nfor npm, local TypeScript, and `.wasm`\n\nmodules. Virtual `bash()`\n\nfor shell. Auto-install packages on import.\n\n`require()`\n\nany `.wasm`\n\n— library exports become Python methods, WASI programs become callable commands; drop one in `bin/`\n\nto run it from `bash()`\n\ntoo. In-process, inherits the sandbox's mounts and permissions, installs nothing on the host.\n\nAppend `.task()`\n\nto any call — parallel fan-out with `asyncio.gather`\n\n, check progress, tail logs, drive interactive sessions with `send()`\n\n, await later.\n\nDisk-backed workspace — host mounts (`ro`\n\n/`rw`\n\n), input files at `/files/`\n\n, outputs persisted to real directories. New and modified files are detected and returned per call.\n\n`help()`\n\n, `search()`\n\n, `inspect()`\n\n, `preview()`\n\n— agents discover only what they need, when they need it.\n\n`display()`\n\nrenders 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`\n\nroutes that compose across tools.\n\nParselbox uses [ Deno](https://deno.com) for the secure sandbox runtime.\n\n**1. Install Deno**\n\n```\n# macOS / Linux\ncurl -fsSL https://deno.land/install.sh | sh\n\n# Windows (PowerShell)\nirm https://deno.land/install.ps1 | iex\n```\n\n**2. Install Parselbox**\n\n```\npip install parselbox\n```\n\nWire 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.\n\n**Example:**\n\n``` python\nimport asyncio\nimport os\nfrom textwrap import dedent\nfrom parselbox import Parselbox\nfrom parselbox.bridge import HTTPBridge, ShellBridge\n\nclass Analytics:\n    def summarize(self, repos: list) -> dict:\n        \"\"\"Aggregate repo stats.\"\"\"\n        stars = [r[\"stars\"] for r in repos]\n        return {\"count\": len(repos), \"avg_stars\": round(sum(stars) / len(stars))}\n\nconfig = {\"mcpServers\": {\"playwright\": {\"command\": \"npx\", \"args\": [\"@playwright/mcp@latest\"]}}}\n\nasync def main():\n    async with Parselbox(\n        mcp=config,\n        context={\n            \"analytics\": Analytics(),\n            \"github\": HTTPBridge(base_url=\"https://api.github.com\", token=os.environ[\"GITHUB_TOKEN\"]),\n            \"sh\": ShellBridge(\"bash\"),\n        },\n        network=True,\n        allow_runtime_packages=True,\n        packages=[\"numpy\", \"npm:lodash\"],\n        output_dir=\"./workspace\",\n    ) as sbx:\n        # Discover available tools\n        await sbx.execute_code(\"sbx.search('navigate|get')\")\n\n        # Scrape Hacker News for GitHub links in a real browser\n        await sbx.execute_code(dedent(\"\"\"\n            import re\n            playwright.browser_navigate(url=\"https://news.ycombinator.com\")\n            text = playwright.browser_snapshot()\n            repos = re.findall(r'github\\\\.com/([\\\\w.-]+/[\\\\w.-]+)', text)[:5]\n        \"\"\"))\n\n        # Fetch star counts in parallel, then summarize via the context bridge\n        await sbx.execute_code(dedent(\"\"\"\n            import asyncio\n            results = await asyncio.gather(*[github.get.task(f\"/repos/{r}\") for r in repos])\n            repo_data = [{\"name\": r[\"data\"][\"name\"], \"stars\": r[\"data\"][\"stargazers_count\"]}\n                         for r in results if r.get(\"ok\")]\n            analytics.summarize(repo_data)\n        \"\"\"))\n\n        # Chart it — matplotlib auto-installs on import\n        result = await sbx.execute_code(dedent(\"\"\"\n            import matplotlib.pyplot as plt\n            plt.barh([r[\"name\"] for r in repo_data], [r[\"stars\"] for r in repo_data])\n            plt.savefig(\"chart.png\")\n        \"\"\"))\n        print(result.files)                  # ['chart.png']\n        image = sbx.read_file(\"chart.png\")\n        # every result carries .output, .files, .stdout, .stderr, .error\n\n        # Serve the whole sandbox as an MCP server\n        await sbx.run_mcp()\n\nasyncio.run(main())\n```\n\nThe Parselbox CLI runs a standalone MCP server — every sandbox option is available as a flag.\n\nTip\n\n**The \"loopback\" trick:**\n\n- Add the Parselbox MCP alongside your existing MCP servers.\n- Point\n`--mcp`\n\nat that same config file. - On startup, Parselbox connects to the other servers, exposes their tools inside the sandbox, and starts its own MCP server.\n\nDon't worry — Parselbox detects and avoids connecting to itself. No infinite loops of doom.\n\n**Example:**\n\n```\n{\n  \"mcpServers\": {\n    \"github\": {},\n    \"linear\": {},\n    \"parselbox\": {\n      \"command\": \"uvx\",\n      \"args\": [\"parselbox\", \"--mcp\", \"/absolute/path/to/mcp.json\"]\n    }\n  }\n}\nuvx parselbox --mcp mcp.json --transport http --port 9000\n{\n  \"mcpServers\": {\n    \"parselbox\": {\n      \"type\": \"http\",\n      \"url\": \"http://localhost:9000/mcp\"\n    }\n  }\n}\nuvx parselbox \\\n  --mcp ./mcp.json \\\n  --transport http \\\n  --host 0.0.0.0 \\\n  --port 8080 \\\n  --file hello.txt \\\n  --mount ./datasets:/data:rw \\\n  --output-dir ./outputs \\\n  --packages pandas,matplotlib \\\n  --package-dir ./cache \\\n  --allow-runtime-packages \\\n  --network \\\n  --serve 3000 \\\n  --memory 2048 \\\n  --timeout 60 \\\n  --env MY_API_KEY=...\npython\nimport asyncio\nfrom parselbox import Parselbox\nfrom agents import Agent, Runner, function_tool\n\nsandbox = Parselbox(\n    mcp={\"mcpServers\": {\"playwright\": {\"command\": \"npx\", \"args\": [\"@playwright/mcp@latest\"]}}},\n    output_dir=\"./outputs\",\n    allow_runtime_packages=True,\n)\n\nagent = Agent(\n    name=\"Research Assistant\",\n    model=\"gpt-5.5\",\n    instructions=f\"You are a world-class research assistant.\\n\\n{sandbox.get_prompt()}\",\n    tools=[function_tool(sandbox.get_tool())],\n)\n\nasync def main():\n    async with sandbox:\n        result = await Runner.run(\n            agent,\n            \"Scrape Wikipedia's 'List of highest-grossing films' with the Playwright MCP. \"\n            \"Plot a bar chart of the top 10 and save it as ./plot.png\",\n            max_turns=30,\n        )\n        print(result.final_output)\n\nasyncio.run(main())\n```\n\nThe context bridge exposes host Python objects inside the sandbox:\n\n`context`\n\n— functions and namespaces as callable tools. Execution pauses, runs on host, returns result.`globals`\n\n— static values (strings, numbers, dicts) copied into the sandbox.`mcp`\n\n— MCP server config (dict or path). Appears as callable namespaces inside sandbox.\n\n**Plain classes** are auto-wrapped — every public method becomes a callable tool; methods starting with `_`\n\nstay private:\n\n``` python\nfrom parselbox import Parselbox\n\nclass Calculator:\n    def add(self, a: float, b: float) -> float:\n        \"\"\"Add two numbers.\"\"\"\n        return a + b\n\nasync with Parselbox(context={\"calc\": Calculator()}) as sbx:\n    await sbx.execute_code(\"calc.add(a=10, b=20)\")\n```\n\nSubclass ** Bridge** for nested namespaces (auto-crawled); annotate a parameter with a Pydantic model and passed dicts convert to it automatically:\n\n``` python\nfrom parselbox import Parselbox\nfrom parselbox.bridge import Bridge\nfrom pydantic import BaseModel\n\nclass Coordinate(BaseModel):\n    x: float\n    y: float\n    z: float = 0.0\n\nclass Sensors(Bridge):\n    def temperature(self) -> float:\n        \"\"\"Read temperature in celsius.\"\"\"\n        return 23.5\n\nclass Robot(Bridge):\n    def __init__(self):\n        self.sensors = Sensors()\n\n    def move(self, to: Coordinate) -> dict:\n        \"\"\"Move robot to a position.\"\"\"\n        return {\"position\": [to.x, to.y, to.z], \"status\": \"reached\"}\n\nasync with Parselbox(context={\"robot\": Robot()}) as sbx:\n    await sbx.execute_code(\"robot.move(to={'x': 1, 'y': 2})\")\n    await sbx.execute_code(\"robot.sensors.temperature()\")\n```\n\nParselbox ships **bridges** for REST, GraphQL, and shell:\n\n``` python\nfrom parselbox import Parselbox\nfrom parselbox.bridge import HTTPBridge, GraphQLBridge, ShellBridge\n\napi = HTTPBridge(\n    spec=\"https://petstore3.swagger.io/api/v3/openapi.json\",\n    base_url=\"https://petstore3.swagger.io/api/v3\",\n)\ngql = GraphQLBridge(\"https://countries.trevorblades.com/graphql\")\nsh = ShellBridge(\"ssh -T user@host\")\n\nmcp = {\"mcpServers\": {\"deepwiki\": {\"type\": \"http\", \"url\": \"https://mcp.deepwiki.com/mcp\"}}}\n\nasync with Parselbox(context={\"api\": api, \"gql\": gql, \"sh\": sh}, mcp=mcp, network=True) as sbx:\n    await sbx.execute_code('api.search(\"GET /pet/*\")')\n    await sbx.execute_code('api.get(\"/pet/1\")')\n\n    await sbx.execute_code('gql.graphql(query=\"{ continents { name } }\")')\n    await sbx.execute_code('gql.graphql(query=\"{ languages { code name } }\")')\n\n    await sbx.execute_code('term = sh.shell.task()')\n    await sbx.execute_code('term.send(\"df -h\")')\n\n    await sbx.execute_code(\"sbx.search('ask|read')\")\n    await sbx.execute_code(\"deepwiki.read_wiki_structure(repoName='pyodide/pyodide')\")\n    await sbx.execute_code(\"deepwiki.ask_question(question='What is Pyodide?', repoName='pyodide/pyodide')\")\n```\n\nRunnable:[bridges.py]\n\nEvery context and MCP call also has a `.task()`\n\nform that runs on the host without blocking the sandbox — for parallel fan-out, long-running jobs, and interactive sessions:\n\n```\njob = sh.exec.task(command=\"ffmpeg -i in.mp4 out.mp4\")   # returns a task immediately\n\njob.status()                   # TaskStatus(state, elapsed, message, logfile)\njob.tail(5)                    # last lines of the task's live log\njob.send(\"q\")                  # message a running interactive process\nawait job.wait(timeout=120)    # block until done — or just `await job`\njob.cancel()\n\n# parallel fan-out\nimport asyncio\nresults = await asyncio.gather(*[api.get.task(f\"/items/{i}\") for i in range(5)])\n```\n\nMCP tools stream their progress and log notifications into the task's logfile. A custom `Bridge`\n\nmethod emits the same way with `self.log()`\n\n, and reads whatever the sandbox queued via `send()`\n\nwith `self.recv()`\n\n:\n\n``` python\nfrom parselbox.bridge import Bridge\n\nclass Exporter(Bridge):\n    def run(self, rows: int) -> str:\n        for i in range(rows):\n            self.log(f\"row {i}/{rows}\")     # appended to task.logfile → tail()\n            for msg in self.recv():         # messages queued by task.send()\n                self.log(f\"got: {msg}\")\n        return \"done\"\n```\n\n**Interactive sessions** — `ShellBridge.shell()`\n\nkeeps stdin open, so a task can drive a live process with `send()`\n\n:\n\n```\nsession = sh.shell.task()               # a live shell — state persists within the session\nsession.send(\"x=21\")\nsession.send(\"echo $((x * 2))\")\n\nimport asyncio\nawait asyncio.sleep(1)                  # give it a beat\nsession.tail(1)                         # \"42\"\n\nsession.cancel()\n```\n\nAn optional first command launches any REPL as the session — e.g. `sh.shell.task(\"python3 -i\")`\n\n.\n\nRunnable:[tasks.py]\n\nParselbox 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.\n\n| Method | Access Level | Description |\n|---|---|---|\nfiles |\nRead / Write | Temp directory at `/files/` . Input files copied here; server uploads stored here. |\nmounts |\nConfigurable | Maps host directories to `/mnt/{name}` . Mode: `ro` (default) or `rw` . |\noutput_dir |\nRead / Write | Maps working directory to a host directory to persist files. If not provided, defaults to a temp directory (wiped on close). |\n\nNote\n\n`/workspace`\n\nis always backed by a real host directory —`output_dir`\n\n(persistent) or an ephemeral temp dir (wiped on close) — enabling Deno streaming,`resolvePath()`\n\n, and`require()`\n\nfor local modules.- Cross the boundary with\n`sandbox.read_file(path)`\n\n(`str`\n\nfor text,`bytes`\n\nfor binary) and`sandbox.write_file(path, content)`\n\n; a persistent`output_dir`\n\nis also readable directly. - Mounts with\n`target=\"skills\"`\n\nare reported by`sbx.info()`\n\nand discoverable via`bash(\"ls /mnt/skills/\")`\n\n.\n\n**Example:**\n\n``` python\nfrom parselbox import Parselbox, Mount\n\nasync with Parselbox(\n    files=[\"data.csv\"],                         # Read/write at /files/data.csv\n    mounts=[\n        Mount(\"./datasets\", \"/data\", \"ro\"),     # Read-only at /mnt/data\n        Mount(\"./workspace\", \"/work\", \"rw\"),    # Read/write at /mnt/work\n    ],\n    output_dir=\"./outputs\"                      # Sandbox files persisted here\n) as sandbox:\n    # Write a file into the sandbox from the host\n    sandbox.write_file(\"greeting.txt\", \"Hello from host!\")\n\n    code = \"\"\"\n    content = open('/files/data.csv').read()               # input file\n    ref = open('/mnt/data/reference.json').read()          # read-only mount\n    open('/mnt/work/processed.txt', 'w').write(content)    # read/write mount\n    open('result.txt', 'w').write(\"Done!\")                 # working dir -> output_dir\n    \"\"\"\n    result = await sandbox.execute_code(code)\n\n    # New / modified files are detected and returned\n    print(result.files)   # ['result.txt', 'greeting.txt']\n    sandbox.read_file(\"result.txt\")\n```\n\nReach the same files from a shell with `bash()`\n\n:\n\n```\nbash(\"echo 'hello from bash' > note.txt && cat note.txt\")   # shell over the workspace\n```\n\nRunnable:[filesystem.py]·[bash.py]\n\nPyodide supports pure-Python packages and many C-extension packages, which must be [pre-built for Pyodide](https://pyodide.org/en/stable/usage/packages-in-pyodide.html) — numpy, pandas, and more ship included.\n\n``` python\nfrom parselbox import Parselbox, Mount\n\n# preload Python + npm packages on startup\nParselbox(packages=[\"numpy\", \"pandas\", \"npm:lodash\"])\n\n# local wheel — mount its dir so Deno can read the host path\nParselbox(packages=[\"file:///host/wheels/pkg.whl\"],\n          mounts=[Mount(\"./wheels\", \"wheels\", \"ro\")])\n\n# remote wheel — needs network access\nParselbox(packages=[\"https://example.com/pkg.whl\"], network=True)\n\n# autoload as imports appear (only official domains when network=False)\nParselbox(allow_runtime_packages=True)\n```\n\nNote\n\nPackage installs write straight to disk — a temp dir by default (wiped on exit). Set `package_dir`\n\nto persist them across sessions, so the next boot is instant with no re-download.\n\nAfter initial package loading, network is blocked by default. Access is configured with Deno's permission controls via `--allow-net`\n\n/ `--deny-net`\n\n. All HTTP from sandboxed code (requests, httpx, fetch) routes through Deno's `fetch()`\n\n.\n\n```\n# Block everything (default)\nParselbox(network=False)\n\n# Allow specific domains (Python API only)\nParselbox(network=[\"api.github.com:443\"])\n\n# Allow everything\nParselbox(network=True)\n```\n\nNote\n\nThe CLI `--network`\n\nflag is a boolean toggle only. Domain allowlists are available via the Python API.\n\nPyodide is **not** a security boundary — sandboxed code can read env vars via `js('Deno.env.get(\"KEY\")')`\n\n, so never pass real credentials in `env`\n\n. Instead, run a credential-injecting proxy on the host and lock the sandbox to it:\n\n```\nasync with Parselbox(\n    network=[\"127.0.0.1:8900\"],                 # sandbox can ONLY reach the proxy\n    env={\n        \"OPENAI_BASE_URL\": \"http://127.0.0.1:8900/v1\",\n        \"OPENAI_API_KEY\": \"phantom-token\",      # harmless; the real key lives on the proxy\n    },\n) as sbx:\n    await sbx.execute_code(\"import openai; openai.OpenAI().chat.completions.create(...)\")\n```\n\nMost SDKs take a `base_url`\n\noverride. For SDK-agnostic interception, set `HTTP_PROXY`\n\n/`HTTPS_PROXY`\n\n/`DENO_CERT`\n\ninstead and route everything through a MITM proxy — Deno's `fetch()`\n\nhonours them at the process level.\n\nRunnable:[basics.py]\n\nParselbox runs Python inside Deno's V8 engine via Pyodide, so Python and JavaScript share the same process memory — interop is seamless.\n\n``` js\n# Basic — auto converts args and results\njs(\"return data.map(x => x * 2)\", data=[1, 2, 3])  # [2, 4, 6]\n\n# Callbacks — Python functions auto-proxied, no create_proxy needed\njs(\"return items.filter(fn)\", items=[1,2,3,4,5], fn=lambda x, *_: x > 3)  # [4, 5]\n\n# Async + Web APIs (Intl, Crypto, URL, TextEncoder)\njs(\"return crypto.randomUUID()\")\n```\n\nEach `js()`\n\ncall runs in a fresh, stateless scope. Python callables are auto-proxied and cleaned up after the call. Binary converts too — `Uint8Array`\n\n/`ArrayBuffer`\n\nresults become Python `bytes`\n\n, and `bytes`\n\narguments become `Uint8Array`\n\ns.\n\n```\n# npm packages — returns proxy + auto-injects in js() scope (alias= to rename)\nlodash = require(\"lodash\")\nlodash.chunk([1, 2, 3, 4], 2)  # [[1, 2], [3, 4]]\n\n# Callbacks work with require'd packages\nlodash.sortBy(data, lambda x, *_: x[\"age\"])\n\n# Also available in js()\njs(\"return lodash.invert({a: 1, b: 2})\")\n\n# Local TypeScript — compiled by Deno, hot-reloads; can import npm internally\nrequire(\"./math_utils.ts\").fibonacci(10)\n\n# .wasm modules & WASI binaries load too — see WASM Tools\n\n# Instances keep their methods — chain them\ndayjs = require(\"dayjs\")\ndayjs(\"2026-06-15\").add(30, \"day\").format(\"YYYY-MM-DD\")   # \"2026-07-15\"\n\n# Class constructors auto-detect `new`\ncolor = require(\"color\")\ncolor(\"red\").darken(0.5).hex()   # \"#800000\"\n\n# Chains work with Python callbacks\nlodash(data).filter(lambda x, *_: x[\"pay\"] > 100).sortBy(lambda x, *_: -x[\"pay\"]).value()\n```\n\nFor files too large to fit in memory, use Deno streams via `resolvePath()`\n\n:\n\n``` js\njs(\"\"\"\n    const path = resolvePath(\"sample.txt\");\n    const info = await Deno.stat(path);\n    return { size: info.size, isFile: info.isFile };\n\"\"\")\n```\n\nPython callbacks work inside streaming pipelines — Deno reads, JS parses, Python classifies each line.\n\n``` python\n# Python module — write it, import it\nopen(\"helpers.py\", \"w\").write(\"def double(x): return x * 2\")\nfrom helpers import double\ndouble(21)  # 42\n\n# TypeScript module — compiled by Deno\nopen(\"transform.ts\", \"w\").write(\"export function upper(s: string) { return s.toUpperCase(); }\")\nrequire(\"./transform.ts\").upper(\"hello\")  # \"HELLO\"\n```\n\nYou can even compile a language to WebAssembly in-sandbox, then `require()`\n\nthe output.\n\nA pure-JavaScript bash ([just-bash](https://github.com/vercel-labs/just-bash)) over the same workspace. Pipes and coreutils work, and `curl`\n\nis backed by `fetch`\n\n. Each call is isolated (`cd`\n\n/`export`\n\ndon't persist); filesystem changes do.\n\n```\nbash(\"echo hello > note.txt && cat note.txt | tr a-z A-Z\")   # \"HELLO\"\nbash(\"grep -rn hello . | wc -l\")\nbash(\"curl -s https://api.github.com/zen\")                   # network rules still apply\n```\n\nRunnable:[javascript.py]·[bash.py]\n\nPyodide can only load packages built for it — so `pandoc`\n\n, `ruby`\n\nor `shellcheck`\n\nare out of reach, and there is no `apt-get`\n\nin a single-process sandbox. Parselbox closes that gap with **WASI**: any program compiled to WebAssembly becomes a tool, with no host install.\n\nA missing capability is just a file.\n\n`require()`\n\ninspects the module and picks the right shape:\n\n``` js\n# Library module (no imports) — its exports become methods\nrequire(\"./fib.wasm\").fib(20)                      # 6765\n\n# Command module (a WASI program) — becomes a callable command\npandoc = require(\"./pandoc.wasm\")\nr = pandoc([\"-f\", \"markdown\", \"-t\", \"html5\"], stdin=\"# Report\")\nr[\"stdout\"].decode()                               # '<h1 id=\"report\">Report</h1>'\n```\n\nA command returns `{\"exit\": int, \"stdout\": bytes, \"stderr\": str, \"missing\": [...]}`\n\n— `missing`\n\nlists any syscalls the binary asked for that aren't implemented, so gaps surface as data rather than a crash.\n\nImportant\n\n**Emscripten builds are not WASI builds.** Much of npm's \"wasm\" (`sql.js`\n\n, `ffmpeg.wasm`\n\n, `tesseract.js`\n\n) is compiled with Emscripten and needs its own JavaScript glue — import those as **npm packages** (`require(\"sql.js\")`\n\n), not as bare `.wasm`\n\nfiles. Both routes work; `require()`\n\ntells you which one a binary needs.\n\n```\nrun(args=None, stdin=\"\", env=None, preopens=None, argv0=None)\n```\n\n—`stdin`\n\n`str`\n\nor`bytes`\n\n;always comes back as`stdout`\n\n`bytes`\n\n.— grant extra guest directories, e.g.`preopens`\n\n`preopens={\"/usr\": \"vendor/usr\"}`\n\nfor a binary that expects its own tree.— some binaries dispatch on their program name (lld becomes`argv0`\n\n`wasm-ld`\n\nbusybox-style).\n\nA WASI command binary (a `.wasm`\n\nexporting `_start`\n\n) in a mount's `bin/`\n\ndirectory becomes a shell command, usable alongside `bash()`\n\n's JavaScript coreutils. Binaries are discovered per call, so a tool written mid-session works immediately.\n\n```\nopen(\"bin/pandoc.wasm\", \"wb\").write(pandoc_bytes)\n\nbash(\"pandoc -f markdown -t plain notes.md | head -3 | tr a-z A-Z\")\n#     ^^ compiled pandoc                      ^^ just-bash builtins\n```\n\nMount a `bin/`\n\nfolder 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`\n\ninto `bin/`\n\nat runtime, which works even when the sandbox's network is restricted to a single allowlisted host.\n\nYou can even build one from source in-process — fetch a WASI clang + `wasm-ld`\n\ninto `bin/`\n\n, compile C to `.wasm`\n\n, then `require()`\n\nthe result. No host toolchain, nothing installed.\n\nNote\n\n- Auto-detected as WASI\n`preview1`\n\nor`wasi_unstable`\n\n(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.\n\nRunnable:[pandoc.py]— fetch a WASI binary ·[compile_c.py]— compile C → wasm in-sandbox\n\nThe `sbx`\n\ntoolkit lets agents discover capabilities on demand instead of loading everything into context up front. Available as `sbx.*`\n\ninside the sandbox.\n\n| Function | Description |\n|---|---|\n`sbx.help()` |\nReturns a full guide to using the sandbox. |\n`sbx.info()` |\nGet sandbox environment info — context, packages, network, mounts, serve etc. |\n`sbx.search(pattern)` |\nSearch tools across all namespaces by name, description, or parameter. |\n`sbx.inspect(tools)` |\nGet detailed schemas and documentation for tools. |\n`sbx.preview(data)` |\nSummarize large or nested data structures — preserves keys, truncates content. |\n\nThe sandbox also exposes a `help()`\n\nbuiltin for per-object introspection:\n\n```\n# Sandbox guide\nhelp()\n\n# Namespace tree view — shows all methods with hierarchy\nhelp(robot)\n# Remote namespace 'robot' — methods execute on the host and return results.\n# Methods:\n# ├── sensors\n# │   └── temperature()\n# └── move()\n\n# Tool details — description, parameters, output schema\nhelp(robot.move)\n# {\"description\": \"Move robot to position.\", \"parameters\": {...}, \"output\": {...}}\n\n# Works on local objects too\nhelp(len)\n```\n\n**Example:**\n\n```\n# Discover what's available\nsbx.info()\n\n# Search for tools across all namespaces\nsbx.search(\"repo|query\")\n\n# Get tool signatures before calling\nsbx.inspect([\"github.search_repositories\", \"db.query\", \"robot.move\"])\n\n# Parallel execution with .task\nimport asyncio\nresults = await asyncio.gather(*[api.fetch.task(id=i) for i in ids])\n\n# Inspect unknown response structure\nsbx.preview(results)\n```\n\nRunnable:[toolkit.py]\n\nAgents can surface results two ways: **inline in the conversation** with `display()`\n\n, or as a **full web app** with `serve`\n\n.\n\nAny HTML an agent passes to `display()`\n\nrenders as a widget beneath its result, in hosts that support [MCP Apps](https://modelcontextprotocol.io).\n\n```\nawait sbx.execute_code(\"\"\"\n    display(\"<h1>Q3 Revenue</h1><p class='text-lg'>Up <b>12%</b> to $4.1M</p>\")\n\"\"\")\n```\n\nTailwind and daisyUI are injected automatically, so plain markup is styled without a build step, and `pbx.call(\"/api/route\", body)`\n\ninside the HTML reaches `@api`\n\nhandlers when `serve`\n\nis on. `display()`\n\nalso accepts a path to an HTML file in the workspace. One view per execution — the last call wins.\n\n**On by default.** `run_mcp(ui=False)`\n\nturns it off, which stops advertising `display()`\n\nto the agent and drops the renderer from the tool. The rendered HTML is always on `result.view`\n\nregardless:\n\n```\nresult = await sbx.execute_code('display(\"<b>done</b>\")')\nresult.view          # full HTML document, or None if display() wasn't called\n```\n\nThe `serve`\n\noption starts a Deno HTTP server inside the sandbox — agents build full web apps on the fly.\n\n```\n# SDK\nsandbox = Parselbox(serve=3000)\n\n# CLI\nuvx parselbox --serve 3000\n```\n\n**Static Files:** Any files written to the Pyodide working directory are automatically served:\n\n```\nopen(\"index.html\", \"w\").write(\"<h1>Hello World</h1>\")\nopen(\"style.css\", \"w\").write(\"h1 { color: blue; }\")\n```\n\nServed at their own paths, with `/`\n\nresolving to `index.html`\n\n; uploaded and input files live under `/files/*`\n\n.\n\n**API Handlers:** Define endpoints using FastAPI-style decorators:\n\n``` python\n@api.get(\"/items\")\ndef list_items(params):\n    limit = int(params.get(\"limit\", 10))\n    return items[:limit]\n\n@api.post(\"/items\")\ndef create_item(body):\n    return {\"id\": len(items) + 1, \"name\": body[\"name\"]}\n```\n\nRoutes are prefixed with `/api/`\n\nautomatically. Verbs: `@api.get/post/put/patch/delete`\n\n.\n\nHandlers can call MCP tools, context functions, and any sandbox code:\n\n``` python\n@api.get(\"/dashboard\")\nasync def dashboard(params):\n    import asyncio\n    sensors, orders = await asyncio.gather(\n        robot.sensors.temperature.task(),\n        store.get.task(\"/orders\", params={\"limit\": 5}),\n    )\n    return {\"temperature\": sensors, \"recent_orders\": orders}\n```\n\n**Built-in Endpoints:**\n\n| Endpoint | Method | Description |\n|---|---|---|\n`/_upload` |\nPOST | File upload (multipart form data) |\n`/_live` |\nGET | SSE stream — connected browsers refresh when static files change (on by default) |\n`/_routes` |\nGET | List registered API handlers |\n\n```\ncurl -F \"file=@photo.png\" http://localhost:3000/_upload\n# {\"uploaded\": [{\"name\": \"photo.png\", \"path\": \"/files/photo.png\", \"size\": 12345}]}\n```\n\nRunnable:[display.py]·[serve.py]\n\nHooks intercept sandbox lifecycle events — log executions, approve tool calls, enforce policies. Pass them via the `hooks`\n\nparameter.\n\n``` python\nfrom parselbox import Parselbox, Callback, ExecutionResult\nfrom parselbox.hooks import Hook\n\nclass AuditHook(Hook):\n    async def pre_execute(self, code: str):\n        print(f\"Executing: {code[:80]}...\")\n\n    async def post_execute(self, result: ExecutionResult):\n        print(f\"Result: {result.output}\")\n\n    async def pre_tool_call(self, callback: Callback):\n        if \"drop\" in str(callback.kwargs).lower():\n            raise PermissionError(\"DROP statements are blocked\")\n\n    async def post_tool_call(self, callback: Callback, result):\n        print(f\"Tool {callback.name} returned\")\n\nasync with Parselbox(\n    context={\"db\": db},\n    hooks=[AuditHook()],\n) as sbx:\n    await sbx.execute_code(\"db.query(sql='SELECT 1')\")\n```\n\n** ElicitHook** — a built-in hook that uses MCP elicitation for human-in-the-loop approval. Enable via\n\n`--elicit`\n\n(CLI) or `run_mcp(elicit=True)`\n\n(API). Only fires if the MCP client advertises elicitation capability — otherwise it's a no-op.\n\n```\n# CLI\nuvx parselbox --mcp mcp.json --elicit\n\n# API\nawait sandbox.run_mcp(elicit=True)\n```\n\n| Hook | Trigger | Use Cases |\n|---|---|---|\n`pre_execute` |\nBefore code runs | Logging, policy checks, code sanitization |\n`post_execute` |\nAfter code completes | Audit trails, result validation |\n`pre_tool_call` |\nBefore a context/MCP call | Approval gates, rate limiting, blocking |\n`post_tool_call` |\nAfter a context/MCP call returns | Logging, result transformation |\n\nRunnable:[hooks.py]\n\n`Parselbox`\n\nhas the following configuration options:\n\n``` python\nfrom parselbox import Parselbox, Mount\n\nsandbox = Parselbox(\n    context=dict(db=db, notify=send_alert),   # Proxied functions and namespaces\n    globals=dict(name=\"hi\", threshold=0.5),   # Static values copied into sandbox\n    files=[\"./input.txt\"],                    # Read/write files at /files/\n    mounts=[\n        Mount(\"./datasets\", \"/data\", \"ro\"),   # Read-only mount\n        Mount(\"./workspace\", \"/work\", \"rw\"),  # Read/write mount\n    ],\n    output_dir=\"./outputs\",                   # Persist sandbox files\n    packages=[\"numpy\", \"npm:lodash\"],         # Install on startup (Python + npm)\n    package_dir=\"./cache\",                    # Persist package cache across sessions\n    allow_runtime_packages=True,              # Auto-install from imports (default: False)\n    network=True,                             # True, False, or [\"domain:port\", ...] (API only)\n    mcp=\"./mcp.json\",                         # Connect MCP servers (path or dict)\n    serve=8080,                               # Enable web server on port\n    memory=2048,                              # WASM memory limit in MB (default: 2048)\n    timeout=60,                               # Execution timeout in seconds (default: 60, 0 disables)\n    hooks=[AuditHook()],                      # Lifecycle hooks\n    env={                                     # Custom env vars (available in Python os.environ)\n        \"OPENAI_BASE_URL\": \"http://proxy/v1\", # SDK base_url overrides for reverse proxy\n        \"OPENAI_API_KEY\": \"phantom\",          # Phantom tokens (real keys on proxy)\n        \"HTTP_PROXY\": \"http://proxy:8080\",    # Deno-level proxy (filtered from os.environ)\n        \"DENO_CERT\": \"/path/to/ca.pem\",       # Custom CA for MITM proxy\n    },\n)\n```\n\nParselbox 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:\n\n```\n  1. exec       HOST ──▶ SANDBOX    your code runs, permission-jailed\n  2. callback   HOST ◀── SANDBOX    code calls a tool as native Python\n  3. result     HOST ──▶ SANDBOX    host runs it with the real credentials\n```\n\nTools *look* like native Python inside the sandbox, but they execute on the host — so **credentials never enter the sandbox**.\n\nParselbox's boundary is **Deno's permission system** — the sandbox starts with nothing and gets only what you configure.\n\n**Filesystem**— isolated temp workspace (wiped on exit); read/write only to paths you pass (`files`\n\n,`mounts`\n\nas`ro`\n\n/`rw`\n\n,`output_dir`\n\n). Package-cache writes lock after startup unless`allow_runtime_packages=True`\n\n.**Network**— off by default (revoked before your code runs). Opt in with`network=True`\n\n, an allowlist`network=[\"host:port\", ...]`\n\n, or`allow_runtime_packages=True`\n\n(package domains only). For authenticated APIs, front it with a proxy — see[Proxy & Credential Injection](#proxy--credential-injection).**Compiled tools (WASI)**— no sockets, so a binary has no network of its own; it sees only the mounts you grant (`ro`\n\nenforced 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`\n\n), 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.\n\n[Code execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp)(Anthropic)[Code Mode](https://blog.cloudflare.com/code-mode/)(Cloudflare)[smolagents](https://huggingface.co/docs/smolagents/en/tutorials/secure_code_execution)(Hugging Face)[Deno + Pyodide Sandbox](https://til.simonwillison.net/deno/pyodide-sandbox)(Simon Willison)", "url": "https://wpnews.pro/news/show-hn-parselbox-an-embeddable-python-sandbox-for-ai-agents", "canonical_source": "https://github.com/thesanjeetc/parselbox", "published_at": "2026-08-21 14:23:56+00:00", "updated_at": "2026-08-21 14:46:14.532669+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools"], "entities": ["Parselbox", "Deno", "Pyodide", "MCP", "Hacker News"], "alternates": {"html": "https://wpnews.pro/news/show-hn-parselbox-an-embeddable-python-sandbox-for-ai-agents", "markdown": "https://wpnews.pro/news/show-hn-parselbox-an-embeddable-python-sandbox-for-ai-agents.md", "text": "https://wpnews.pro/news/show-hn-parselbox-an-embeddable-python-sandbox-for-ai-agents.txt", "jsonld": "https://wpnews.pro/news/show-hn-parselbox-an-embeddable-python-sandbox-for-ai-agents.jsonld"}}