{"slug": "what-if-your-ai-agent-never-had-to-leave-the-browser", "title": "What If Your AI Agent Never Had to Leave the Browser?", "summary": "A developer demonstrated a browser-native AI agent runtime that runs entirely client-side using Pyodide, compiling CPython to WebAssembly so a ReAct-style tool-using loop executes inside a browser tab with no backend, container, or SSH. The demo agent calls arithmetic and virtual-filesystem tools through a deterministic policy function standing in for an LLM, with explicit termination via a final action or an eight-step cap, and the author notes the policy can be swapped for a fetch call to a real model without changing the loop.", "body_md": "Most AI agents today run in a Python process on a server or your laptop. They call APIs, maybe execute shell commands, and return text. But what if the agent's entire runtime lived inside a browser tab? No backend, no container, no SSH. Just JavaScript and WebAssembly, with a Python kernel compiled to WASM.\n\nThis post walks through a minimal agent loop that runs entirely client-side using Pyodide — Python in the browser via WebAssembly. We'll build a tool-using agent that can do arithmetic, read from a virtual filesystem, and stop under explicit conditions. All code is runnable in a modern browser.\n\nServer-side agents have friction:\n\nA browser-native agent flips this. The sandbox is the browser tab. The runtime is WebAssembly. The only network call is loading the Python runtime itself.\n\nWe'll use [Pyodide](https://pyodide.org/) to run CPython in the browser. The agent loop is a simple ReAct-style loop: the model proposes a tool call, we execute it in Python, append the result, and repeat until a termination condition is met.\n\nWe won't call a real LLM here — instead we use a deterministic policy function so the demo is reproducible and offline. Swap the policy for a fetch to an LLM API and the loop is unchanged. We'll call this a \"ReAct-style\" loop because it follows the observe-think-act pattern, not because it implements the exact paper.\n\n**Termination conditions** (explicit):\n\n`final` action.`MAX_STEPS` (default 8).\n\n```\n<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>Browser Agent</title></head>\n<body>\n  <pre id=\"log\"></pre>\n  <script src=\"https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js\"></script>\n  <script type=\"module\">\n    const log = (m) => document.getElementById('log').textContent += m + '\\n';\n    const pyodide = await loadPyodide();\n    await pyodide.runPythonAsync(await (await fetch('agent.py')).text());\n    const result = await pyodide.runPythonAsync('run_agent(\"What is 21 * 2 plus 8?\")');\n    log(result);\n  </script>\n</body>\n</html>\n```\n\n`agent.py`)\n\n``` python\nimport json\nimport re\n\nMAX_STEPS = 8\n\n# --- Tools -------------------------------------------------------------\n\ndef tool_calc(expr: str) -> str:\n    \"\"\"Evaluate a pure arithmetic expression.\n\n    SECURITY WARNING: eval() executes arbitrary Python. This implementation\n    restricts input to digits and operators via a regex whitelist. Do not\n    remove the whitelist. Do not pass user-controlled strings from an\n    untrusted source. For production, use ast.literal_eval or a parser.\n    \"\"\"\n    if not re.fullmatch(r\"[0-9+\\-*/(). ]+\", expr):\n        raise ValueError(f\"unsafe expression: {expr!r}\")\n    return str(eval(expr, {\"__builtins__\": {}}, {}))\n\n_FS = {\"notes.txt\": \"remember: 42\"}\n\ndef tool_read_file(path: str) -> str:\n    if path not in _FS:\n        raise FileNotFoundError(path)\n    return _FS[path]\n\nTOOLS = {\n    \"calc\": tool_calc,\n    \"read_file\": tool_read_file,\n}\n\n# --- Policy (replace with an LLM call) ---------------------------------\n\ndef policy(question: str, history: list) -> dict:\n    \"\"\"Deterministic stand-in for an LLM. Returns a dict action.\n\n    Swap this for a fetch() to your LLM of choice. The loop below does not\n    care where the action came from.\n    \"\"\"\n    if not history:\n        # First turn: extract a math expression from the question.\n        m = re.search(r\"([0-9+\\-*/(). ]+)\", question)\n        if m:\n            return {\"type\": \"tool\", \"name\": \"calc\", \"args\": {\"expr\": m.group(1).strip()}}\n        return {\"type\": \"final\", \"content\": \"no expression found\"}\n\n    last = history[-1]\n    if last[\"role\"] == \"tool\" and last[\"name\"] == \"calc\":\n        # Second turn: add 8 as required by the question.\n        return {\"type\": \"tool\", \"name\": \"calc\", \"args\": {\"expr\": f\"{last['result']} + 8\"}}\n    if last[\"role\"] == \"tool\" and last[\"name\"] == \"calc\":\n        return {\"type\": \"final\", \"content\": last[\"result\"]}\n    return {\"type\": \"final\", \"content\": \"done\"}\n\n# --- Agent loop --------------------------------------------------------\n\ndef run_agent(question: str) -> str:\n    history = []\n    for step in range(MAX_STEPS):\n        action = policy(question, history)\n        history.append({\"role\": \"assistant\", \"action\": action})\n\n        if action[\"type\"] == \"final\":\n            return f\"[step {step}] {action['content']}\"\n\n        if action[\"type\"] == \"tool\":\n            fn = TOOLS.get(action[\"name\"])\n            if fn is None:\n                history.append({\"role\": \"tool\", \"name\": action[\"name\"],\n                                \"error\": \"unknown tool\"})\n                continue\n            try:\n                result = fn(**action[\"args\"])\n                history.append({\"role\": \"tool\", \"name\": action[\"name\"],\n                                \"result\": result})\n            except Exception as e:\n                history.append({\"role\": \"tool\", \"name\": action[\"name\"],\n                                \"error\": str(e)})\n\n    return f\"[halted: exceeded MAX_STEPS={MAX_STEPS}]\"\n```\n\n`run_agent` runs the loop. Step 0 calls `calc(\"21 * 2\")` → `42`. Step 1 calls `calc(\"42 + 8\")` → `50`. Step 2 returns No server. No API key. The entire agent state lives in the tab and is discarded when you close it.\n\nReplace `policy` with something like:\n\n``` python\nimport json\nfrom js import fetch  # Pyodide exposes the browser fetch API\n\nasync def policy_llm(question, history):\n    resp = await fetch(\n        \"https://api.example.com/v1/chat\",\n        {\"method\": \"POST\",\n         \"headers\": {\"Content-Type\": \"application/json\"},\n         \"body\": json.dumps({\"q\": question, \"history\": history})}\n    )\n    data = await resp.json()\n    return json.loads(data.action)\n```\n\nYou'll need to make `run_agent` async and `await` the policy. The rest of the loop is identical. Note that calling an LLM from the browser exposes your API key to the user — use a short-lived token or a proxy you control.\n\n`eval` untrusted input.`tool_calc` example uses a regex whitelist and an empty `__builtins__` map. That is a mitigation, not a guarantee. For anything real, use `ast.literal_eval` or a dedicated parser.\nTry it: drop the two files in a folder, serve with `python -m http.server`, and open the page. You'll have an agent that never leaves the browser.", "url": "https://wpnews.pro/news/what-if-your-ai-agent-never-had-to-leave-the-browser", "canonical_source": "https://dev.to/gu_cci_f94bedb90083e6aab4/what-if-your-ai-agent-never-had-to-leave-the-browser-117o", "published_at": "2026-09-29 05:30:38+00:00", "updated_at": "2026-09-29 05:46:45.851094+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "artificial-intelligence"], "entities": ["Pyodide", "WebAssembly", "CPython", "JavaScript", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/what-if-your-ai-agent-never-had-to-leave-the-browser", "markdown": "https://wpnews.pro/news/what-if-your-ai-agent-never-had-to-leave-the-browser.md", "text": "https://wpnews.pro/news/what-if-your-ai-agent-never-had-to-leave-the-browser.txt", "jsonld": "https://wpnews.pro/news/what-if-your-ai-agent-never-had-to-leave-the-browser.jsonld"}}