We kept the shell and left the VM anyway

A ready box in 27 ms and 305 KB. The same code runs in the browser tab.

SS

By Sanket Sahu

5th Aug 2026

We kept the shell and left the VM anyway

Most coding agents run in a virtual machine. Ours builds full-stack React Native apps with a Linux-like shell in TypeScript, a real Postgres and a live preview, and there is no VM and no cloud sandbox anywhere in the request path. The agent runs in a Vercel Function. The same code also runs in the browser tab.

I'm Sanket Sahu, co-founder of RapidNative and GeekyAnts. I spend most of my time on systems design and open source. RapidNative turns a prompt into a working Expo app, which is what forced this architecture: a preview has to run somewhere, and renting a Linux box per user does not survive contact with a free tier.

Two of the three pieces are open source, so every number below is checkable without signing up for anything. Lifo is a Linux-like OS engine in TypeScript, MIT. Tinbase gives a Supabase-compatible API for local dev without Docker, and the in-memory engine our agent introspects is 6.7 MB, and yes it also runs in the browser.

Everyone provisions a sandbox

Provisioning a sandbox is the default, and it is a well-served default. Vercel Sandbox went GA this January, Cloudflare has a Sandbox SDK, and E2B, Modal and Daytona all sell the same shape, most of them Firecracker underneath. We deploy on Vercel and we do not use its sandbox. Not because it's bad, it's good, but because a code-generating agent turns out not to need what it provides.

camelAI reached the same conclusion from the other direction recently: they moved off VMs into a Cloudflare Durable Object and, to get there, they removed bash and replaced it with explicit JavaScript methods. That's the honest fork in the road. They gave up the shell to leave the VM. We kept the shell and left the VM anyway, because the OS became a library.

The reason a VM feels mandatory is that "the agent needs a computer" sounds like it needs a computer. Decompose what it actually needs:

  • a filesystem it can read, write, walk, and importantly watch
  • a shell and processes, with pipes, globs, redirects and exit codes
  • a database to apply migrations against and introspect
  • a way to see the result run

Now read the tool calls from a real agent session and count them. It is overwhelmingly cat, ls, grep, find, sed, write-file, then a bundler or a migration. It is not arbitrary native binaries. Every one of those four requirements is implementable in userland, and all four are data structures.

Before and after: a microVM behind a network boundary, versus one JS runtime
fig 1Every arrow that crosses the dashed line is a cat that costs a round trip, plus a cold start before the first one.

Linux as a library

The substrate is Lifo, an OS engine written in TypeScript that runs in both Node and the browser. Not a shell emulator. It ships:

  • a kernel with a virtual filesystem, virtual /proc and /dev providers, and IndexedDB persistence
  • a bash-like interpreter with pipes, redirects, globs, variables, job control and history
  • 60+ commands, including awk, sed, grep, find, tar, curl, node and npm
  • a Node compat layer, so node script.js runs against shimmed fs, path and http
  • a WASM package manager: lifo install ffmpeg, python, sqlite

npm install works. npm start works. curl works. Dev servers run, git runs, WASM packages like ffmpeg run. /proc and job control are the tell: this is an OS reimplementation, not a cat polyfill.

What it costs, measured in bench/RESULTS.md and reproducible from the repo:

Lifo (browser)WebContainers (browser)
cold start to a ready box~27 ms (26 ms load + 0.7 ms boot)~6.6 s (1.0 s import + 5.6 s boot)
runtime footprint~305 KB gzipped core~75–85 MB wasm Node engine
COOP/COEP headers requirednoyes (SharedArrayBuffer)
also runs server-sideyes, same boxno

In-process in Node, Sandbox.create averages 0.58 ms. Commands run at 202K ops/s in Node and 357K in the browser; 1 KiB file reads at 594K and 833K ops/s. A box is cheap enough to create per request and throw away.

And the disclaimer, in Lifo's own words from its comparison doc, because overclaiming here would poison everything else in this post:

Reads like Linux, isn't Linux. Lifo is a clean-room reimplementation of the OS and Node.js APIs in TypeScript, not a Linux distribution or a VM. It doesn't run native Linux binaries.

It behaves like a Unix box across a wide range of real work: shells, git, npm, dev servers, previews, WASM packages, agent code etc. What it isn't is a hypervisor, which is a question about isolation rather than capability, and one worth answering carefully further down.

Two filesystems that sync

The agent has two filesystems and they are deliberately different things.

Supabase Postgres is the durable filesystem. A project's files are rows. The blobs are stored in Supabase Storage and references in the files table.

The Lifo VFS is the runtime filesystem. It is what cat, sed and the bundler actually see.

A turn hydrates the VFS from Postgres at the start, and commits back at the end. Nothing streams row-by-row, and that boundary is the point rather than an optimisation:

  • The dirty set comes from the kernel's own watcher, not from bookkeeping in the tool layer. A sed -i that touches five files reports five files, and the tool never had to understand sed. Add a command to the box and the accounting already works.
  • You get turn-level transactions for free. Writes accumulate in the VFS and commit once, when the stream completes. A crash mid-turn reverts the whole turn instead of leaving a half-written project. In a container that's snapshots, overlayfs, or a diff you maintain by hand.
  • Persistence is stored data, not infrastructure you keep alive. There is no disk to attach and no machine to keep warm between turns. This is the same conclusion camelAI reached with SQLite and R2, arrived at from a different starting point.

One turn, both surfaces. The shell calls and the database calls run in the same request, in the same heap, with no round trip between them.

One security note, since the model is writing shell commands. Nothing it produces is trusted. After a command runs, the files it changed are read back and emitted as ordinary file writes. The stream carries content, never instructions for producing content. A design that replayed the agent's command on the client would have to agree on tool versions, starting state and determinism, and would drift silently the moment it didn't.

The durable filesystem in Postgres hydrating into the runtime VFS for the length of one request
fig 2The VFS lives inside the request. The disk does not.

The database is a variable

Same move, applied to Postgres. The engine is pg-mem, in-process, no I/O, cheap enough to rebuild per session.

Migrations are the artifact. supabase/migrations/*.sql is the source of truth, in Supabase CLI naming so the files stay portable to hosted Supabase. There is no JSON record of the schema and no hand-maintained TypeScript mirror, because both drift.

The asymmetry is the design:

  • writes: the agent authors SQL. This is Supabase's own model, so there's no DSL for the model to learn and nothing to translate.
  • reads: always execute against the engine. "What does the schema look like" is answered by applying the migrations, so the answer reflects what actually happened.

Writes are validated before they land. db_migration_new applies the candidate SQL on top of every prior migration first. If it fails, the file is never written and the error goes back to the model as feedback on that turn. A rejected migration is cheaper than a broken app.

After a migration applies, TypeScript types are regenerated from the live schema, and lints run against the resulting catalogue rather than against SQL text. The one worth naming: RLS enabled with no policy is an error, not a warning. It returns zero rows, so the app looks broken with no error anywhere. That's a bug class the model cannot see and a human debugs for an hour.

Policies are real, not parsed. A ~40-line auth bootstrap gives pg-mem an auth schema with an auth.uid() that reads request.jwt.claim.sub, exactly as Supabase does, plus the anon and authenticated roles. So a policy is testable behaviourally: set the role, set the claim, run the query, count the rows. Worth being precise, since this is the kind of claim people rightly poke at: the default session is superuser and therefore bypasses RLS, which is real Postgres behaviour, so assuming a role is the only meaningful way to test a policy.

The footprint, against the thing it replaces:

tinbase (pg-mem)Supabase local
install size6.7 MB2,291 MB
memory at boot71 MB1,441 MB
processes112 containers + Docker
1,000 inserts / 1,000 filtered reads0.83 s / 0.81 s1.13 s / 1.04 s
A candidate migration applied against the in-memory engine, rejected, and fed back to the model before any file is written
fig 3The rejection arrow is the point: a bad migration never reaches the filesystem.

A DB MCP without the server

The in-memory schema exposes exactly the tool surface a database MCP server would: list tables, describe a table, run a read-only query. There is no process, no transport and no handshake. A tool call is a function call into the same heap the agent loop is already running in.

Which is a reframe of what a database MCP server is for. It exists to cross a process boundary and reach a Postgres somewhere else. Delete the boundary and you keep the interface for free.

The same box runs in the browser

This is not the main argument, but it's the part nobody anticipates.

Because the box is a library rather than a machine, the client can run the identical code. In the browser, the bundler runs as a process inside the box and registers an HTTP server on a virtual port. A service worker routes /_sw/{boxId}/{port}/ into the box's port registry. An iframe points at that URL and gets a real app. Edit a file, the kernel watcher fires, the bundler rebuilds, and HMR flows back through the same service worker. For cases where service workers are tricky, we use blob files with postMessage to imitate the whole stack, non-Safari browsers on iOS!! You know it!

The database is another process on another virtual port, answering @supabase/supabase-js unchanged. The app's queries never leave the tab.

Server-side, the same box is a Vercel Function: Node on Fluid Compute, 300-second duration. Not Edge, which we tried to want and didn't need. (Still experimenting with this one.)

One box, two runtimes, no sandbox provider in either. A browser boot is 0.66 ms and needs no cross-origin isolation headers, which matters more than it sounds: requiring COOP/COEP reshapes your entire site to accommodate a preview.

The bundler, the database and the app running as processes on virtual ports inside a single browser tab
fig 4The bundler, the database and the app all live in one tab. Nothing is served.

The ledger

VM or container sandboxin-process
cold starthundreds of ms to seconds0.58 ms
cost per sessionmetered computenone
one catnetwork round tripfunction call
revert a failed turnsnapshots, overlayfsdrop the dirty set
runs in a browser tabnoyes, same code
runs in a serverless functionneeds an external serviceyes, same code
native Linux binariesyesno
memory and CPU ceilingsenforced by the hypervisoryour problem

The last two rows are not throwaways.

Where this breaks

Native Linux binaries. Lifo reimplements the OS and Node APIs, it doesn't run ELF. Anything that needs a real native toolchain needs a real machine.

Isolation is a spectrum, and you should pick your point on it deliberately. By default a box shares the host's JS runtime, which is fine when the code being run is your own agent's output on behalf of the user who asked for it. If you want a genuine execution boundary, run the box inside isolated-vm and you get a separate V8 isolate with its own heap. Beyond that, if you need hard memory ceilings, process limits and CPU accounting enforced by something that cannot be argued with, that is what microVMs are for and you should use one.

Which is the whole point, stated properly: you are not sandboxing untrusted code, you are sandboxing a code generator. Different threat model, different architecture. The industry inherited the first one and applied it to the second, and paid for a hypervisor to run grep.

TL;DR

The three things people provision a VM for are a filesystem, a shell and a schema. All three can be data structures in the same process as the agent loop. We run the agent in a Vercel Function with a Lifo box for the shell and filesystem, Supabase Postgres as the durable disk that the VFS hydrates from at the start of a turn and commits back to at the end, and pg-mem as the schema engine where migrations are validated before the file is ever written. The identical box runs client-side too, bundling and serving the user's Expo app and answering its database calls, so the preview needs no server either. A ready box costs 27 ms and 305 KB in a browser, and 0.58 ms in Node. There is no VM and no cloud sandbox in the request path. The tradeoff is native code: Lifo reimplements the OS and Node APIs rather than running ELF binaries. If you want a hard execution boundary, run the box in isolated-vm; if you need hypervisor-enforced memory and CPU ceilings, that is the one case where a microVM is still the right answer.

Lifo is MIT at lifo.sh. Tinbase is at tinbase.dev. The benchmarks in this post are in each repo's bench/ and reproducible.

What do you think about this architecture? Please let me know your thoughts about Lifo and Tinbase in the comments. Thank you!

Start now

Ready to build your app?

Turn your idea into a production-ready React Native app in minutes.

Free tools to get you started

Questions

Frequently asked questions

Does this run untrusted code safely?

By default, no, and that is deliberate. A box shares the host's JS runtime, which is appropriate when you are running your own agent's output on behalf of the user who requested it. For a genuine execution boundary, run the box inside isolated-vm for a separate V8 isolate. If you need hypervisor-enforced memory ceilings, process limits and CPU accounting, use a microVM. The distinction that matters is that you are sandboxing a code generator, not untrusted code.

Can it run native Linux binaries?

No. Lifo is a clean-room reimplementation of the OS and Node.js APIs in TypeScript, not a Linux distribution or a VM, so it does not execute ELF binaries. Anything requiring a real native toolchain needs a real machine. It does run shells, git, npm, dev servers, previews and WASM packages such as ffmpeg.

How does this compare to WebContainers?

In the browser, a Lifo box reaches a ready state in roughly 27 ms against roughly 6.6 s for WebContainers, with a ~305 KB gzipped core against a ~75-85 MB wasm Node engine. Lifo also needs no COOP/COEP cross-origin isolation headers, and the same box runs server-side, which WebContainers does not.

Where is project state stored if there is no VM?

In Supabase Postgres. Project files are rows, with blobs in Supabase Storage. A turn hydrates the runtime virtual filesystem from Postgres at the start and commits the dirty set back at the end, so persistence is stored data rather than infrastructure kept alive between turns. That also gives turn-level transactions: a crash mid-turn reverts the whole turn.