# What If Your AI Agent Never Had to Leave the Browser?

> Source: <https://dev.to/gu_cci_f94bedb90083e6aab4/what-if-your-ai-agent-never-had-to-leave-the-browser-117o>
> Published: 2026-09-29 05:30:38+00:00

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.

This 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.

Server-side agents have friction:

A 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.

We'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.

We 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.

**Termination conditions** (explicit):

`final` action.`MAX_STEPS` (default 8).

```
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Browser Agent</title></head>
<body>
  <pre id="log"></pre>
  <script src="https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js"></script>
  <script type="module">
    const log = (m) => document.getElementById('log').textContent += m + '\n';
    const pyodide = await loadPyodide();
    await pyodide.runPythonAsync(await (await fetch('agent.py')).text());
    const result = await pyodide.runPythonAsync('run_agent("What is 21 * 2 plus 8?")');
    log(result);
  </script>
</body>
</html>
```

`agent.py`)

``` python
import json
import re

MAX_STEPS = 8

# --- Tools -------------------------------------------------------------

def tool_calc(expr: str) -> str:
    """Evaluate a pure arithmetic expression.

    SECURITY WARNING: eval() executes arbitrary Python. This implementation
    restricts input to digits and operators via a regex whitelist. Do not
    remove the whitelist. Do not pass user-controlled strings from an
    untrusted source. For production, use ast.literal_eval or a parser.
    """
    if not re.fullmatch(r"[0-9+\-*/(). ]+", expr):
        raise ValueError(f"unsafe expression: {expr!r}")
    return str(eval(expr, {"__builtins__": {}}, {}))

_FS = {"notes.txt": "remember: 42"}

def tool_read_file(path: str) -> str:
    if path not in _FS:
        raise FileNotFoundError(path)
    return _FS[path]

TOOLS = {
    "calc": tool_calc,
    "read_file": tool_read_file,
}

# --- Policy (replace with an LLM call) ---------------------------------

def policy(question: str, history: list) -> dict:
    """Deterministic stand-in for an LLM. Returns a dict action.

    Swap this for a fetch() to your LLM of choice. The loop below does not
    care where the action came from.
    """
    if not history:
        # First turn: extract a math expression from the question.
        m = re.search(r"([0-9+\-*/(). ]+)", question)
        if m:
            return {"type": "tool", "name": "calc", "args": {"expr": m.group(1).strip()}}
        return {"type": "final", "content": "no expression found"}

    last = history[-1]
    if last["role"] == "tool" and last["name"] == "calc":
        # Second turn: add 8 as required by the question.
        return {"type": "tool", "name": "calc", "args": {"expr": f"{last['result']} + 8"}}
    if last["role"] == "tool" and last["name"] == "calc":
        return {"type": "final", "content": last["result"]}
    return {"type": "final", "content": "done"}

# --- Agent loop --------------------------------------------------------

def run_agent(question: str) -> str:
    history = []
    for step in range(MAX_STEPS):
        action = policy(question, history)
        history.append({"role": "assistant", "action": action})

        if action["type"] == "final":
            return f"[step {step}] {action['content']}"

        if action["type"] == "tool":
            fn = TOOLS.get(action["name"])
            if fn is None:
                history.append({"role": "tool", "name": action["name"],
                                "error": "unknown tool"})
                continue
            try:
                result = fn(**action["args"])
                history.append({"role": "tool", "name": action["name"],
                                "result": result})
            except Exception as e:
                history.append({"role": "tool", "name": action["name"],
                                "error": str(e)})

    return f"[halted: exceeded MAX_STEPS={MAX_STEPS}]"
```

`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.

Replace `policy` with something like:

``` python
import json
from js import fetch  # Pyodide exposes the browser fetch API

async def policy_llm(question, history):
    resp = await fetch(
        "https://api.example.com/v1/chat",
        {"method": "POST",
         "headers": {"Content-Type": "application/json"},
         "body": json.dumps({"q": question, "history": history})}
    )
    data = await resp.json()
    return json.loads(data.action)
```

You'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.

`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.
Try 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.
