Engineering · Agents on the BEAM

The bots write their own tools now. A WASM box runs them safely.

The last post showed an agent writing its own tools, and flagged the catch: it's running code the model wrote, so you can't point it at production. This is the fix, built into the actual Crowd. A bot gets a run_code tool, writes JavaScript, and it runs in a WebAssembly sandbox with no access to anything. The BEAM already contains a crash; WASM contains the malice. Real code, real numbers, real JS.

Josef Richter

The Crowd bots have a small set of tools decided at design time: read a URL, search the web, grep the repo. Useful, but fixed. What I wanted, after the LFE experiment, was to let a bot write and run its own code at runtime. The problem is obvious: that's arbitrary model-written code, and a bot on the open internet running it unsandboxed is a bad day waiting to happen.

WebAssembly solves exactly this. A WASM module has no ambient authority. On its own it can compute over its own memory and nothing else: no filesystem, no network, no clock, no syscalls, unless you hand it a specific function to do so. So a snippet that tries something nasty just can't. It has no door.

That composes with what the Crowd already has:

flowchart TD
    M["Model writes JavaScript
(the run_code tool)"] --> B["Bot: a supervised process
BEAM contains the crash"] B --> W["WASM sandbox
no files, no net, no syscalls
WASM contains the malice"] W --> R["Result back to the bot"]

The BEAM contains faults (a bot that crashes is restarted alone). WASM contains malice (a snippet can't do anything you didn't grant). You need both, and now the Crowd has both.

The guest speaks JavaScript, because the models can't write WAT

A first instinct is to have the model emit WebAssembly directly. That's a dead end: WAT is a low-level stack machine, it's rare in training data, and the small models the Crowd runs (an 8B, a 1B) mangle it. What those models do write fluently is JavaScript.

So the guest is a JavaScript interpreter, compiled to WASM. I used boa, a pure-Rust JS engine: it builds straight to wasm32-wasip1 with nothing but a Rust toolchain, no C and no Emscripten. The model writes JS, and the interpreter runs it inside the sandbox. The whole guest is about forty lines of Rust: read the script from stdin, eval it, coerce the result to a string, print it.

One new tool

The Crowd's tools are just functions with a name and a schema, dispatched in one place. So run_code is a three-line addition:

brain.ex
defp exec_tool("run_code", %{"code" => code}, _root), do: TownCrowd.Sandbox.eval(code)

TownCrowd.Sandbox.eval/1 feeds the JavaScript to the interpreter over WASI stdin, reads the result off stdout, and returns a plain string, the same shape as every other tool. It never raises. It's gated behind a sandbox: true flag on a persona, so no existing bot changes.

Keeping it warm

The interpreter is 2.8 MB, and compiling it (Wasmtime JITs it to native) costs about 190 ms. Doing that per call would be absurd, and it's fully reusable, so the sandbox compiles it once at startup onto a shared engine. Every eval/1 then spins up a fresh, throwaway WASI instance on that engine, runs the snippet, and drops it. Fresh instance per call means no state leaks between callers; the expensive compile never repeats.

The difference is the whole argument for keeping it warm:

cold vs warm
recompile the interpreter every call:    ~180 ms
compiled once + fresh instance per call:  0.5 to 7 ms

The instance is started by the calling bot's Task, not by the sandbox server, so heavy compute never bottlenecks anything, and if the bot dies its instance dies with it.

What a real run looks like

This is the sandbox running the kind of JavaScript a small model actually produces:

run_code
arithmetic  ->  23                 // 2 + 3 * 7
array work  ->  1,2,3,4,5          // [5,3,1,2,4].sort((a,b)=>a-b).join(',')
recursion   ->  6765               // function f(n){return n<2?n:f(n-1)+f(n-2)} f(20)
a JS error  ->  JS error: Error: nope
no network  ->  undefined          // typeof fetch
no files    ->  undefined          // typeof require

The last two lines are the point. Inside the sandbox there is no fetch, no require, no process, no way out. The model can compute anything it likes and reach nothing it shouldn't.

A bad snippet can't take anything down

A snippet that throws comes back as an ordinary error string (JS error: ...), and the next call runs fine. No rescue clauses holding the world together. If a whole bot fell over, the supervisor would restart just that one, which is the Crowd's original “let it crash” property. The two layers stack: WASM contains the bad code, the BEAM contains the bad process.

There's a test for each of these (runs JS, reuses the interpreter warm, contains an error, confirms no host globals, refuses junk without raising), and they pass:

mix test
5 tests, 0 failures

Skills are memories again

Here's the honest tradeoff, and it's the mirror image of the LFE post. There, a self-authored tool became a hot-loaded BEAM module: kept in the code table, shared by every agent, for free. A WASM sandbox doesn't give you that. Each run is a throwaway instance.

So on the Crowd a skill goes back to being a memory, not a module. You store the JS source in the bot's memory (the Crowd already persists transcripts), and replay it into the interpreter when needed. If a skill proves itself, you promote it to a real first-class tool. You lose the code table's free persistence and sharing, and rebuild it, cheaply, out of the memory the Crowd already has. That's the price of running untrusted code safely, and it's worth it.

The honest parts

Two things worth being straight about.

boa, not QuickJS. I recommended QuickJS earlier, and it's the faster, more complete engine. But QuickJS is C, so compiling it to WASM means the wasi C toolchain (wasi-sdk, clang). boa is pure Rust and builds with just rustup target add wasm32-wasip1, which is why it's what actually runs here. Same idea, a JS interpreter in a sandbox; swap in QuickJS if you outgrow boa. boa is a from-scratch engine and not the fastest (naive recursive fib(20) takes ~60 ms), which is fine for “compute an exact answer” and would matter for anything heavier.

No CPU cap yet. A runaway while(true){} is only bounded by a call timeout on the Elixir side. A real deployment would also cap the guest with Wasmtime fuel or epoch interruption so the loop itself is cut off, not just abandoned.

Where this leaves the trilogy

The BEAM was always going to contain the crash. WASM is what contains the code. Put a supervised process around a sandboxed JS interpreter and the bot can write its own tools without you having to trust a word of what it wrote.