The eval Wall, and Three Forks Around It

A coda to An OS Where the AI Is Never a Principal. The series argued, in theory, that you should run AI agents in vats. Cloudflare Workers blocks eval, so Endo's central Compartment.evaluate() doesn't run. Here are the three architectures that survive that wall — container vats, Durable-Object vats, hybrid — and the one question that picks the right one.

*A coda to An OS Where the AI Is Never a Principal. The series argued, in theory, that you should run AI agents in vats. This post is what that argument runs into when you actually try to put one on Cloudflare.*

The previous twelve posts built a position. Aegis. Vat-per-agent. SES + Compartments. Eventual send. CapTP between vats. A control plane that is small enough to inspect. The model proposes; the membrane disposes. All of that was theory.

Then I went to deploy it.

I picked Cloudflare because everything else I run lives there, the cold-start budget is small enough to support per-agent isolation, and the Durable Object primitive maps surprisingly cleanly onto "a long-lived stateful single-tenant agent." That last sentence is the one I want to revisit. Because the moment I tried to instantiate the actual vat — SES, lockdown, compartment, evaluate guest code — the runtime hit a wall, and I had to decide what the project was *willing to compromise on* to keep moving.

This post is that decision record, written down honestly. There are three forks. The right one for any given project depends on the answer to one question, and I'll get to that question last.

The wall

Cloudflare Workers run inside V8 isolates, and those isolates run under a Content Security Policy that disables eval and new Function. This is a deliberate platform choice — it removes a whole class of injection attacks at the runtime layer. It is also, as it happens, exactly the surface that Endo's Compartment.evaluate() depends on.

A vat, in the Endo sense, is a Compartment plus a queue of deliveries plus persistent state. The Compartment is the part that matters here: it's a hardened JavaScript realm into which you can .evaluate(sourceText, endowments) a chunk of guest code. That's how you load an agent's body. That's how you sandbox a tool implementation. That's how you crank a vat past its first delivery.

Take eval away and Compartment.evaluate() throws. SES is still there as a library; lockdown() will partially apply, mostly; harden() works because it's pure intrinsic walking. But the central move — *give me a piece of source text and let me run it in an isolated realm* — is gone. And without it, there is no SwingSet, no vat host, no dynamically-loaded agent, no "untrusted code in a compartment." The framework's central abstraction is not portable to this runtime.

I want to be precise about what does and doesn't work, because this matters for the forks:

  • `harden()` — works. It's Object.freeze walking the graph.
  • `@endo/eventual-send/shim.js` — works. It installs HandledPromise on the global Promise via monkey-patch, no eval.
  • `E()` — works, once the shim is loaded. Promise pipelining is a runtime data structure, not a code-evaluation thing.
  • `@endo/marshal` — works. Pure serialization, no eval.
  • `@endo/captp` — works. Wraps marshal + eventual-send over a send/receive channel.
  • `lockdown()` — partial. It tries to redefine the global eval, which on Workers has already been removed by CSP. Sometimes it succeeds noisily, sometimes it throws. Skip it. Use the shim directly.
  • `Compartment.evaluate(sourceText)` — dies. This is the wall.

So the messaging layer of the capability story — the part you'd reach for to build an ocap-discplined microservice fabric — actually does run on Workers. What doesn't run is the isolation layer that makes the messaging layer *load-bearing* in the first place. Without compartments, you have capability discipline by convention, not by enforcement. The agents have to be trusted, because there's no membrane between them.

That's the fork. You can give up enforcement and ship to Workers. You can keep enforcement and ship to containers. You can mix.

Fork one: real vats in a container

The unflinching answer is: if you want SES + Compartments + SwingSet, run them in Cloudflare Containers.

A Container in this context is a Node process (or whatever runtime you specify in the Dockerfile) that a Worker can route requests into, with the DO-binding model wrapping it. The runtime inside the container is unrestricted V8 — eval is back, new Function is back, dynamic module loading is back. Endo runs to specification. SwingSet boots. You can have a vat per agent, each in its own Compartment, each receiving deliveries through E() and CapTP, each persisting state through the SwingSet kernel's crank journal.

The architecture I'd draw:

[Worker: routing, auth, billing, rate limits]
     │
     ▼
[DO: per-visitor session bridge — holds the WebSocket via hibernation API]
     │  (CapTP frames over WS)
     ▼
[Container: SwingSet kernel + vat:agent-A, vat:agent-B, vat:agent-C, ...]

The Worker terminates visitor traffic and does the cheap stuff. The DO holds the per-visitor capability tab and proxies CapTP frames. The container runs the actual vat kernel.

This used to be operationally heavy. As of the wrangler release in September of 2025, containers run under wrangler dev locally — wrangler builds the Dockerfile and routes the binding to a local container instance through your Docker daemon, with the same shape it takes in production. The autoscaling story is different locally (one instance per class, no DO-driven scale-out) and the per-instance limits aren't enforced, but the round-trip is real and the iteration loop is fast. The previous "you have to preview-deploy to test the container path" friction is gone.

What you pay: container-minutes for the vat host, where you were paying isolate-time for the Worker. For the workloads where you reached for vats in the first place — long-lived stateful agents, many of them, each receiving deliveries — that's the right cost shape. Workers' per-request billing isn't built for things that want to think for hours.

What you get: every property the series argued for. Real compartment membranes between agents. Deterministic re-execution from the crank journal — when the host crashes you replay deliveries from the journal and the vat ends up in exactly the state it would have been in. Dynamic agent code loading — visitor uploads a skill, it lands in a compartment, the existing agents can't see it unless you grant them a capability to. The full ocap discipline of the theory is enforceable, not just aspirational.

This is the fork I take when the workload is "I want to host AI agents I don't trust."

Fork two: vat-shaped agents in Durable Objects

The second fork is what you do if the agent code is yours and you trust it, but you still want the capability-style messaging discipline — E(), CapTP, bootstrap-object-per-actor, promise pipelining between actors. You don't need compartments because you wrote both sides. You do need stateful long-lived actors that can talk to each other without HTTP round-trip semantics.

The shape:

[Worker: routing] ──▶ [DO: agent-A] ─┐
                                      ├── CapTP over internal-fetch / DO-to-DO WS
                     [DO: agent-B] ──┘

Each agent is a DO. The DO's state.storage plays the role of vat state. The DO's lifetime is the agent's lifetime. The bootstrap object — the thing CapTP serves on connection — is the agent's public face. Two agents talking to each other open a CapTP channel through an internal fetch between their DOs.

You install only the eventual-send shim, never lockdown(), never anything that touches Compartment. The shim ordering matters — it has to be the first import in your bundle so HandledPromise is installed before any code calls E(). You verify by checking the bundled output, because esbuild will happily hoist side-effect imports if you aren't careful.

What you get: capability-style messaging discipline, promise pipelining (E(agent).x().then(...) doesn't round-trip until you await the eventual result), stateful agents that survive deploys, per-agent auto-scaling and routing for free.

What you give up: the membrane. Two agents in the same DO run in the same JS context — neither can isolate the other. Cross-DO is trusted-to-trusted; the trust boundary is at the DO edge, not inside it. You can't load agent code dynamically — every agent type is statically compiled into the bundle. You don't get SwingSet's deterministic replay; when a DO restarts you have to rebuild state from the storage layer with your own consistency story.

This is the fork I take when the agent code is mine, the threat model is "buggy code or compromised LLM-generated tool calls, not malicious agents-as-tenants," and I want to spend isolate-time rather than container-minutes.

There is also a thing worth noting: this codebase already runs an AI agent as a DO. The receptionist mask in Themis Cortex is a Durable Object that holds session state, dispatches tool calls, talks to slow services, and survives reconnects. Adding CapTP between such DOs to get capability-style discipline between *multiple* agents is a much smaller leap than re-platforming the whole thing onto containers. If you're starting from a stateful-Worker codebase, this fork is the cheap one and it gets you most of the benefits.

Fork three: hybrid

The third fork is what you do when the answer is "both." Some agents are tenants — visitor-uploaded, untrusted, sandboxed per real compartments. Other agents are yours — trusted, long-lived, capability-discilined but not compartmentalized.

The shape is the union of the prior two:

[Worker] ──▶ [DO: session bridge] ──▶ [trusted agents as DOs (capability-discipline)]
                  │                       │
                  │                       ▼
                  └──▶ [Container: SwingSet with untrusted vats]

CapTP runs across all the edges. A trusted DO agent can hold a capability to a vat in the container and call methods on it through CapTP. A vat can hold a capability to a DO agent and call methods on *it*. The membrane is at the boundary, not in the middle.

This is more code and more deploy surface, but it's also the only fork that lets you have first-party agents that scale to many cheap concurrent visitors *and* a managed tier for hosted third-party agents that need real isolation. When you offer that mix as a product, this is the shape.

I won't pretend the operational story is small. Cross-edge CapTP frames have to be authenticated. The container needs its own auth context. The DO that bridges to the container has to maintain liveness for the WS sessions on both sides. You take this fork because you have a product reason that justifies the operational tax.

The decision rule

The question that picks the fork is not "which is cleaner?" or "which is cheapest?" It is:

Do you need to run code you don't write?

If the answer is yes — visitor-uploaded skills, third-party plugins, anything an attacker might supply — you need the compartment membrane. That is the container path (fork one), or the container half of the hybrid (fork three). There is no fork that gives you membrane isolation on Workers, today. The runtime says no.

If the answer is no — every agent is yours, the threat model is bugs and prompt injection, not malicious tenants — you don't need the compartment. You can spend the messaging-discipline of CapTP without the isolation cost of compartments, and you can take the DO path (fork two). You give up some properties from the series — *enforced* least-authority between agents, deterministic replay, dynamic loading — and you keep most of the rest, including the part about a small trusted core and a small inspection surface.

If the answer is "today no, tomorrow yes" — and that's a real product trajectory, not wishful thinking — start in fork two and design the boundary so fork three is incremental. Trusted agents stay where they are. The container shows up later, on the other side of an authenticated CapTP edge.

What this changes about the series

The series wrote "vat" everywhere. I want to leave the language in place. But I want to be honest about what "vat" means in each fork:

  • Fork one — Container vats. "Vat" is a vat. Compartment, kernel-managed deliveries, journal, full enforcement.
  • Fork two — DO vats. "Vat" is a metaphor. The agent is in a stateful runtime with capability-discipline messaging, no compartment. You get the *interface* of a vat without the *isolation* of a vat.
  • Fork three — Hybrid vats. Both. The interface is consistent across the edge; the enforcement is not.

The properties of the series — least authority, least knowledge, integrity-first, smallness of the trusted base — degrade gracefully across the forks. Membrane enforcement is the property that drops first when you give up compartments. Everything else survives the move to DOs more or less intact, including the most important one: the agent is never a principal. Even in fork two, the visitor and the operator hold the authority; the agent only proposes; capabilities are still discrete, named, and revocable. They just aren't *isolated* in the operating-system sense.

That's not a small concession. But it isn't the whole story, either.

What I'm doing

For the codebase this blog ships from, I'm starting in fork two and writing the boundary as if fork three is coming. The agents are mine. The threat model is wrong-headed tool calls and inputs that should never have been trusted, not malicious tenants. The capability discipline of E() and CapTP between Durable Objects buys most of the properties I argued for, the operational shape is one I already understand from running stateful workers in production, and the bill is per-isolate rather than per-container-minute.

When the day comes that I want to host an agent I didn't write — and the trajectory says it does come — I'll add a container worker on the other side of a CapTP edge. Until then, the simpler thing is the right thing.

The eval wall is real. It isn't infinite. There are three good ways around it. Pick the one that matches the answer to the one question, and don't pretend you've gotten enforcement when you've only gotten discipline.

End of Transmission.