Machine Learning One

WebAssembly: The Almost-Perfect Agent Substrate

How a browser technology designed for safe, fast client-side execution has the potential to be an ideal agentic sandbox.

In the previous article, we derived seven properties an ideal agent VM would have: memory isolation, capability-based security, fuel metering, deterministic execution, a simple instruction set, fast compilation, and a portable binary format.

WebAssembly — a technology designed for running C++ game engines in browsers — satisfies six of them out of the box. The seventh (fuel metering) is available as an opt-in feature in every major WASM runtime. Let's see how.

WASM Meets Our Wishlist

PropertyWASM Feature
Memory isolationLinear memory: a flat byte array, no host pointers
Capability securityImport/export system: guest has only what host provides
Fuel meteringWasmtime's consume_fuel mode
DeterminismSpec-mandated: same inputs → same outputs
Simple ISA~170 instructions, mostly integer math + control flow
Fast compilationSingle-pass compilation in microseconds
Portable binary.wasm runs on any platform with a runtime

This is not a coincidence. Browser sandboxing and agent sandboxing face the same fundamental problem: running untrusted code safely. The browser needs to run JavaScript and WASM from any website without letting it escape. We need to run LLM-generated code without letting it escape. Same problem, same solution.

The Subset We Actually Need

WASM has ~170 instructions. We need about 30. Here is the subset our agent runtime uses:

Types: i32 only. No i64, no f32, no f64. Every value in our system is either a 32-bit integer or a pointer (which is also a 32-bit integer, since WASM uses 32-bit linear memory addressing).

Memory: memory.grow, i32.load, i32.store. We allocate memory from the host side, so the guest rarely touches these directly.

Arithmetic: i32.const, i32.add, i32.sub, i32.mul, i32.div_s, i32.rem_s, i32.and, i32.or, i32.ne, i32.eq, i32.lt_s, i32.gt_s, i32.le_s, i32.ge_s.

Control flow: block, loop, br, br_if, if/then/else/end, return, call.

Locals: local.get, local.set, local.tee.

That's it. No floating point, no SIMD, no tables, no globals, no reference types, no exception handling, no threads, no tail calls. The LLM doesn't need any of those features to write programs that fetch URLs, call AI models, and process strings.

Why does a small subset matter? Because the LLM is the programmer. Every feature we add to the instruction set is another feature the LLM can misuse, misunderstand, or hallucinate. A smaller surface area means more reliable code generation.

Learning WAT: The WebAssembly Text Format

WASM has two representations: a binary format (.wasm) and a human-readable text format called WAT (.wat). We'll work exclusively in WAT because:

  1. The LLM generates text, not binary
  2. WAT is human-readable, so we can debug what the LLM generates
  3. Compilation from WAT to WASM is a single function call in wasmtime

WAT uses S-expressions — the same nested parentheses syntax as Lisp. If you can read (+ 1 2), you can read WAT.

Your First WAT Program

(module
    (func $run (result i32)
        (i32.const 42)
    )
    (export "run" (func $run))
)

Let's break this down:

  • (module ...) — everything lives inside a module
  • (func $run (result i32) ...) — defines a function named $run that returns one i32
  • (i32.const 42) — pushes the constant 42 onto the stack
  • (export "run" (func $run)) — makes the function callable from the host as "run"

The function body is a stack machine. (i32.const 42) pushes 42 onto the stack. The (result i32) declaration tells WASM that whatever is on top of the stack when the function ends is the return value.

Local Variables

(module
    (func $run (result i32)
        (local $a i32)
        (local $b i32)

        (i32.const 10)
        (local.set $a)

        (i32.const 32)
        (local.set $b)

        (i32.add (local.get $a) (local.get $b))
    )
    (export "run" (func $run))
)
  • (local $a i32) — declares a local variable $a of type i32 (initialized to 0)
  • (local.set $a) — pops the stack and stores the value in $a
  • (local.get $a) — pushes $a's value onto the stack
  • (i32.add ...) — pops two values, pushes their sum

This returns 42 (10 + 32).

Conditional Branching

(module
    (func $run (result i32)
        (local $x i32)

        (i32.const 7)
        (local.set $x)

        (if (result i32) (i32.gt_s (local.get $x) (i32.const 5))
            (then (i32.const 1))
            (else (i32.const 0))
        )
    )
    (export "run" (func $run))
)
  • (i32.gt_s ...) — signed greater-than comparison, pushes 1 (true) or 0 (false)
  • (if (result i32) ...) — conditional with a result type; both branches must leave one i32 on the stack

This returns 1 because 7 > 5.

Loops

(module
    (func $run (result i32)
        (local $sum i32)
        (local $i i32)

        (i32.const 0)
        (local.set $sum)

        (i32.const 1)
        (local.set $i)

        (block $break
            (loop $continue
                ;; if i > 10, break
                (br_if $break (i32.gt_s (local.get $i) (i32.const 10)))

                ;; sum = sum + i
                (i32.add (local.get $sum) (local.get $i))
                (local.set $sum)

                ;; i = i + 1
                (i32.add (local.get $i) (i32.const 1))
                (local.set $i)

                ;; continue loop
                (br $continue)
            )
        )

        (local.get $sum)
    )
    (export "run" (func $run))
)

This computes 1 + 2 + ... + 10 = 55.

WASM loops work differently from most languages:

  • (loop $continue ...) defines a label. (br $continue) jumps back to the beginning of the loop.
  • (block $break ...) defines a label. (br $break) jumps to the end of the block.
  • This block+loop pattern is how you build while loops in WAT.

Host Function Imports

This is where WASM becomes an agent substrate. Imports declare functions that the host provides:

(module
    ;; Import a host function: http.get takes a pointer, returns (pointer, error)
    (import "http" "get" (func $http.get (param i32) (result i32 i32)))

    ;; Import memory from the host
    (memory (import "env" "memory") 1)

    (func $run (result i32)
        ;; ... call $http.get with a pointer to a URL ...
        (i32.const 0)
    )
    (export "run" (func $run))
)
  • (import "http" "get" ...) — the guest declares that it needs a function called get from module http
  • The host must provide this function when instantiating the module, or instantiation fails
  • The guest cannot access anything the host doesn't import

This is capability-based security. The guest's entire interface with the outside world is declared upfront in the import section.

Multi-Value Returns

Notice (result i32 i32) — two return values. WASM supports multi-value returns, which is perfect for our error-code convention:

;; Call http.get, which returns (pointer, error_code)
(call $http.get (local.get $url_ptr))

;; Pop in REVERSE order: error first, then pointer
(local.set $err)
(local.set $ptr)

;; Check the error
(if (i32.ne (local.get $err) (i32.const 0))
    (then (return (local.get $err)))
)

;; If we get here, $ptr is valid

The reverse-order popping is a quirk of stack machines. http.get pushes ptr first, then err. So when popping, err comes off first.

The Stack Machine Mental Model

Every WAT instruction operates on an implicit stack:

Instruction              Stack (top on right)
-----------              --------------------
(i32.const 10)           [10]
(i32.const 20)           [10, 20]
(i32.add)                [30]
(i32.const 5)            [30, 5]
(i32.mul)                [150]

Function calls consume arguments from the stack and push results:

(i32.const 42)           [42]        ;; argument for http.get
(call $http.get)         [ptr, err]  ;; two return values
(local.set $err)         [ptr]       ;; pop err
(local.set $ptr)         []          ;; pop ptr

The inline syntax (i32.add (i32.const 10) (i32.const 20)) is just syntactic sugar — the arguments are pushed in order, then the instruction executes. Both forms compile to identical WASM.

What WASM Doesn't Give Us (Yet)

WASM's instruction set is Turing-complete, but it's designed for compilers, not for LLMs. Several things are missing or awkward:

No string literals. WASM has no concept of strings. To pass a string to a host function, you need to: (1) allocate space in linear memory, (2) write the bytes at a known offset, (3) pass the offset as an i32 pointer. This is cumbersome for an LLM that wants to write (call $http.get "https://example.com").

No argument macros. Loading a function argument requires 5 lines of boilerplate: declare two locals (pointer and error), call sys.argv, set the error, set the pointer, check the error. Every. Single. Argument.

Locals must be declared at the function top. WAT requires all (local ...) declarations before any instructions. This forces the LLM to plan all variables upfront, which is unnatural for step-by-step code generation.

These are not WASM limitations per se — they're WAT ergonomic gaps that we'll solve with a preprocessor in later articles. The preprocessor will let us write:

;; This is what the LLM actually generates (with our extensions):
(argv 0 $url_ptr)
(call $http.get (local.get $url_ptr))
(local $body_err i32) (local $body_ptr i32)
(local.set $body_err)
(local.set $body_ptr)
(check $body_err)
(call $dbg.log "fetched the URL successfully")
(resv $body_ptr)
(i32.const 0)

Instead of the raw WAT equivalent, which is roughly three times longer. But that's a story for Article 8.

Why Not WASI?

WebAssembly has a standardized system interface called WASI (WebAssembly System Interface). It provides file access, network sockets, clocks, random numbers, and more. Why not use it?

Because WASI's security model is subtractive. It starts with a full system interface — files, sockets, environment variables — and you restrict it by removing capabilities. Our model is additive: the guest starts with nothing, and we grant specific capabilities one at a time. These are fundamentally different philosophies.

With WASI, you must audit the entire surface area and ensure nothing dangerous leaks through. With our approach, the guest literally cannot do anything we have not explicitly wired up. There is no wasi_snapshot_preview1 import table with dozens of functions the guest might call — there are only the 5 or 10 functions we chose to expose.

There is a second, more practical reason: token efficiency. WASI programs are written in languages that compile to WASM (Rust, C, AssemblyScript). The LLM would need to generate Rust, compile it to WASM, and ship the binary. Our LLM generates WAT directly — a few hundred bytes of text that compiles in microseconds. The token budget for generating WAT is a fraction of what generating compilable Rust would require.

Why Not a Custom Language?

A reasonable question: if we're going to preprocess WAT anyway, why not design our own language? Why start with WASM at all?

Three reasons:

  1. WASM has industrial-grade runtimes. Wasmtime, Wasmer, and WasmEdge have been hardened by thousands of contributors. Writing our own sandbox would take years and never match their security guarantees.

  2. WASM is a compilation target. If we ever want to let users write agent programs in Rust, C, Go, or AssemblyScript, those languages already compile to WASM. We get multi-language support for free.

  3. WAT is close enough. The preprocessing we need is limited to three mechanical transformations: macro expansion, string inlining, and local hoisting. We're not designing a new language — we're adding a thin ergonomic layer on top of a proven foundation.

Exercises

  1. Factorial: Write a WAT program that computes the factorial of 7 (= 5040) using a loop. Use the block+loop+br_if+br pattern from the sum example.

  2. Absolute value: Write a WAT function that takes a local initialized to -42 and returns its absolute value using if/then/else.

  3. Collatz steps: Write a WAT program that counts the number of Collatz sequence steps from 27 to 1. (If n is even, n = n/2. If n is odd, n = 3n+1. Count steps until n = 1.) The answer is 111.

On this page