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.
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:
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:
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:
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:
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 Crowd: many supervised bots, no agent framework, because the runtime is one.
- Skills are modules: an agent writing its own tools, elegant but unsafe, on LFE.
- This one: the same idea made safe on the real Elixir Crowd, by running the model's JavaScript where it can't reach anything.
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.