cd /news/ai-agents/what-if-your-ai-agent-never-had-to-l… · home › topics › ai-agents › article
[ARTICLE · art-141493] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

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

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.

by read4 min views4 publishedSep 29, 2026

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 the Python runtime itself.

We'll use Pyodide 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)

import json
import re

MAX_STEPS = 8


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,
}


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:
        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":
        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"}


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:

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @pyodide 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/what-if-your-ai-agen…] indexed:0 read:4min 2026-09-29 · —