Give an AI agent a computer and it uses all of it. We run Linux as a userspace WebAssembly program so every agent can have its own, and we build that up one syscall at a time.
architecture
Give an AI agent a computer and it will try to use all of it. It runs shell commands, writes files it wants to keep, starts a dev server, installs a package it read about thirty seconds ago. The moment your product lets an agent do real work, you owe it a real machine. And not one machine: one per agent, per session, spun up on demand and thrown away when the task is done.
There are two normal ways to hand out a throwaway machine, and neither is good at this. A container starts in a fraction of a second, but you pay for it the whole time it idles, and a thousand idle containers is a real bill. A microVM is properly isolated but heavy: slower to boot, hungrier at rest, awkward to pack thousands-to-a-host.
We took a different bet. We run Linux as a userspace program, a WebAssembly guest, on a runtime that’s already running. So a sandbox isn’t a host you boot. It’s something you log into. Starting one is closer to opening a file than booting a computer.
That sentence does a lot of work, and the rest of this post is spent earning it. Here’s where we end up. Every line below runs on something we build from nothing: the basic contract every Linux program expects from an operating system, start a process, open a file, bind a port, the thing the standards people call POSIX.
const session = await handle.createSession("pi", {
env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },
});
await handle.sendPrompt(session.sessionId, "Write a hello world script to /home/user/hello.js");
const content = await handle.readFile("/home/user/hello.js");
To get there, two surfaces have to grow together: the SDK you write against, and the syscall surface underneath that makes each SDK call possible. The diagram on the right tracks your scroll. As you read each step, it grows to match, and you can watch the boundary at the bottom never move.
Start with the atom: one wasm module, running by itself, able to do almost nothing.
import { agentOS, setup } from "@rivet-dev/agentos";
const vm = agentOS();
export const registry = setup({ use: { vm } });
registry.start();
A raw wasm module is pure computation in a box. It can crunch numbers all day, but on its own it can’t open a file or reach the network. Anything a program can’t do by itself, start another program, ask who’s logged in, open a connection, it does by asking the operating system. Each of those requests is a syscall.
The standard that lets a wasm guest make any syscalls at all is called WASI, and the version we start from, wasip1, is deliberately tiny. The guest can read the clock, pull some randomness, and read or write a few files it was handed up front. That’s the entire surface.
Drop a shell into that guest and it falls over on the first interesting thing you ask:
$ echo hi && ls /home/user
sh: cannot fork: function not implemented
No fork, no exec, no wait, so nothing can start a second process. No getuid, so there’s no such thing as a user. No connect or listen, so there’s no network. Every real command-line program assumes all three. The rest of this post closes that gap, and the one rule we never break is that we close it without ever handing the guest a single real host call.
A program that can’t start another program isn’t much of an operating system. WASI has no way to express fork and exec, so we add them ourselves, as a host import module called host_process. The guest’s standard C library (libc, the layer almost every Linux program goes through to reach the kernel) calls it like any ordinary syscall.
Here’s the part to notice. The guest never starts a process itself. It hands a request to the front desk, and the kernel, the only thing back there, keeps the master list of what’s running and hands back a PID. The guest holds a ticket number, never the machinery.
That first command now does what you’d expect:
$ echo hi && ls /home/user
hi
hello.js node_modules
From the SDK, that’s one-shot commands or long-lived ones:
// One-shot
const result = await agent.exec("echo hello && ls /home/user");
console.log(result.stdout, result.exitCode);
// Long-running, streamed
const { pid } = await agent.spawn("node", ["/home/user/server.js"]);
One more gap closes here. A tool that calls getuid to find out who it is would still fail, so host_user gives it the VM’s own identity. Other projects in the wasm world hit this same wall. WASIX is an effort to standardize exactly this missing POSIX layer. We solve it from inside our own kernel instead, and I’ll come back to why.
Once more than one process exists, files start to matter, because files are how processes hand work to each other. Every VM gets its own filesystem, and none of its calls reach the host disk. The useful part: any path inside the guest can be backed by storage that lives somewhere else. Point one at an S3 bucket:
const vm = agentOS({
software: [pi],
mounts: [
{
path: "/workspace/data",
plugin: { id: "s3", config: { bucket: "my-bucket", prefix: "agent-data/", region: "us-east-1" } },
},
],
});
await agent.writeFile("/workspace/hello.txt", "Hello, world!");
const content = await agent.readFile("/workspace/hello.txt");
Before the mount, the path simply isn’t there. After it, the same read works, and the bytes live in S3:
$ cat /workspace/data/config.json
cat: /workspace/data/config.json: No such file or directory
# ... add the mount ...
$ cat /workspace/data/config.json
{ "ready": true }
Underneath is a thin translation layer, so an ordinary program never knows anything unusual is happening. When it writes its output or reads its input, those go to a bridge the kernel owns, not to a real file on the host. When it opens a path, the path is checked against the short list of folders that mount allows. A string full of ../.. can’t climb out into a sibling mount or onto the host. There’s no host path down there to climb to.
Read-only mounts refuse writes but still allow traversal, so an agent can read inputs it isn’t allowed to edit:
$ rm /inputs/dataset.csv
rm: cannot remove '/inputs/dataset.csv': Read-only file system
$ find /inputs -name '*.csv'
/inputs/dataset.csv
A dev environment that can’t open a port is half a dev environment, and “start a server, then go look at it” is most of what building software feels like. Base WASI has no general socket API, so we add host_net: TCP connect, listen, send, receive. It runs through a socket table the kernel owns, under the same permission policy as everything else. Loopback-only unless you say otherwise.
Guest code binds a port like any Node process. Before host_net, reaching it fails. After, it answers:
$ node server.js & # listens on :3000
$ curl localhost:3000
curl: (7) Failed to connect to localhost port 3000
# ... host_net arrives ...
$ curl localhost:3000
<!doctype html><title>it's alive</title>
And you reach it from outside without a sidecar:
// Server to server, inside the VM
const res = await agent.vmFetch(3000, "/");
// A shareable, time-limited public URL
const preview = await agent.createSignedPreviewUrl(3000);
console.log(preview.path, preview.expiresAt);
The network stack lives inside the kernel, which is the whole reason there’s no proxy to run. vmFetch and the preview URL are the host side of that same socket table, reaching in.
There’s a question we’ve been skipping. We added host_process, host_net, and the rest as imports, but no normal program calls them by name. grep calls libc, libc makes ordinary syscalls, and on a stock toolchain those syscalls simply don’t exist on wasm. So every program an agent might reach for has to be compiled for this surface first.
That’s what the registry is. Each command, the coreutils, grep, jq, curl, sqlite3, and the agents themselves, is built from source to the wasm32-wasip1 target and shipped as a small package: the wasm binary plus a descriptor with its name and a permission tier. Rust tools go through cargo with a custom-built std; C tools go through wasi-sdk’s clang.
The piece that makes unmodified programs work is a patched libc. We patch Rust’s std and wasi-libc so that fork and exec lower to proc_spawn, getuid to the user module, and connect to net_connect, every one of them routed to the same host imports from the steps above. The program thinks it’s talking to Linux. It’s talking to us.
That’s one sandbox. A wasm module that couldn’t fork a shell is now a Linux box that survives everything you’ve thrown at it, and every capability still bottoms out behind that same front desk. The catch is that the whole machine is a userspace wasm program, which sounds like it should be slow. That turns out to be the best thing about it.
None of this matters for agents if a sandbox takes seconds to show up. Here is where being a wasm module on a shared runtime, instead of a booted VM, stops being a curiosity.
A new sandbox isn’t a machine you boot. It’s an isolate: a fresh, walled-off slot inside an engine that’s already running. Nothing boots. No kernel, no init, no virtual hardware to bring up.
Remember the two bad options from the top. The container’s problem was idle cost, the bill you pay while it sleeps. On our hardware a sleeping sandbox sits at about [IDLE_MEM_MB], so that bill mostly goes away, and a single host packs roughly [SANDBOXES_PER_HOST] before memory, not CPU, runs out. The microVM’s problem was weight, slow to boot and hard to pack. A fresh sandbox cold-starts in around [COLD_START_MS]. Measure a microVM the same way and you’re an order of magnitude off on boot time and idle footprint, which are precisely what a hypervisor charges you for.
Two pieces buy this. The v8 accelerator runs a JavaScript guest close to native speed instead of interpreting it. And snapshotting lets a sandbox sleep with its filesystem intact and wake in about [WAKE_MS], so an idle agent costs almost nothing and is ready the instant it’s wanted again.
No new syscalls. We made the thing far faster and far cheaper, and the security surface is byte-for-byte what it was the moment the network landed. When the boundary is a kernel you wrote instead of a hypervisor you inherited, going fast is an optimization that leaves the security story completely alone.
A fast, throwaway Linux box is the hard part, but it isn’t the whole job. An agent isn’t a single command you run and collect. It’s a long-lived session that edits, runs, checks, and tries again, and you want a lot of them going at once without their work bleeding together. So an orchestrator hands each agent its own isolate and keeps the sessions apart, all of them sitting on the same kernel surface we built up earlier.
The agent itself needs nothing new underneath. Spawning it is host_process. Its edits are path_open. Its dev server is host_net. The model drives the loop where a shell used to, and that’s the only thing that changed.
import { agentOS, setup } from "@rivet-dev/agentos";
import pi from "@agentos-software/pi";
const vm = agentOS({ software: [pi] });
export const registry = setup({ use: { vm } });
registry.start();
const session = await handle.createSession("pi", {
env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! },
});
await handle.sendPrompt(session.sessionId, "Write a hello world script to /home/user/hello.js");
const content = await handle.readFile("/home/user/hello.js");
That’s the code from the top of the post. Every call in it has a floor you’ve watched get poured. On paper, anyway.
A diagram is easy to draw and easy to fake, so here’s the honest version.
Start with the reflex objection: a userspace wasm “Linux” is a toy, not a real security boundary. It’s the reverse. A container shares the host kernel’s full surface of 400-plus syscalls and trusts seccomp to fence off the dangerous ones. Here the guest can only call the [SYSCALL_COUNT] imports we chose to expose, plus the handful of host modules we wrote and can read line by line. There’s no host kernel to break into, because the guest never gets to address it. Every syscall we didn’t implement is one an agent can’t reach for to surprise us.
You don’t have to take the diagram’s word for it, either. Strace a full agent session and every line lands on one of our host modules or the WASI shim. The whole session touches the real host [HOST_CALL_COUNT] times, and those are the imports we wrote, not a leak to the OS underneath. I went looking for a call that escaped to the real kernel and couldn’t find one.
So we run real software and watch what breaks. The proof that matters is the boring one: a coding agent opening a project, editing files across a tree, starting a dev server, serving a live preview, with every action resolving to host_process, path_open, or host_net.
What’s still rough, plainly: programs that introspect the machine see fiction, because calls like getrlimit and the /proc entries that report CPU count return plausible constants. fork then keep running in both halves is a hard case; fork then exec, the shell pattern, is the one we handle well. Some ioctls degrade, so a few terminal UIs misread the window size. And an open socket does not survive a snapshot. A woken sandbox keeps its filesystem, not its live connections. Naming these is the point. A sandbox you can’t describe the edges of isn’t one you should trust an agent inside.
It runs Doom.

Nobody writes a compatibility shim to keep Doom happy. It just wants a machine that does what it claims: allocate memory, map a framebuffer, read input, run a tight loop at speed. That it plays here, at full frame rate, is the part the syscall tables can’t fake.
None of this came from nowhere. Bellard’s JSLinux showed a browser could host an emulated machine. CheerpX and WebVM pushed that to a real Linux userland. WASIX wrote down the POSIX that base WASI skipped. We aimed the same idea at one job: a sandbox cheap enough that every agent gets its own, and contained enough that you can hand one over without thinking twice. The interesting part was never that Linux runs here. It’s what you get to build once a fresh Linux box costs almost nothing.