We give every user a computer and pay for almost none of them Cloudflare's Construct product gives every user a Linux machine, but almost none are running because the agent loop runs inside a Durable Object while Linux is summoned only for tool calls, a design that has cut costs by scaling with usage instead of signups. The original backend, a Bun and Elysia monolith on a VPS, was migrated to Cloudflare Workers on 30 March 2026, and the team moved to the Sandbox SDK after it became generally available on 13 April 2026. The approach was independently reached by camelAI, which also runs its coding agent in a Cloudflare Durable Object rather than a VM. We give every user a computer and pay for almost none of them - engineering - cloudflare - durable-objects - infrastructure Every Construct user gets a Linux machine. It has a shell, a filesystem, Python, LibreOffice, ripgrep, and a disk they can fill up. Right now, almost none of them are running, and that is the whole design. We have been building it this way since March, and this is how the agent loop ended up inside a Durable Object, how Linux became something we summon for a tool call, and how the disk stopped being a disk. A note on the code in this post: everything below is pseudocode, and the internal names are stand-ins. The behaviour, the constants, and the tradeoffs are exactly what we run. A computer per user is a bill per user The default way to run an AI agent is to give it a Linux box. This is not a silly choice. Agents are trained to reach for bash, bash implies a filesystem and a process table, and the easiest way to provide those is a machine that stays up between turns. The trouble is what that costs, and we know the shape of that bill because we signed it first. Construct's original backend was a Bun and Elysia monolith on a VPS: SQLite on local disk, nginx out front, and roughly 1,300 lines of container management handing every user their own box. It worked fine. It also meant that growing the product meant growing a fleet of real machines with real disks, billed through the long stretches where nobody was typing anything. We migrated off it on 30 March 2026. The commit is titled "migrate backend from VPS monolith to Cloudflare Workers", and everything in this post starts there: Hono on Workers, a per-user WebSocket hub in a Durable Object with hibernation, a container object that archived to R2 on sleep and restored on wake, D1 for relational state, R2 for files, and the frontend on Workers assets. Two days later we moved what was left of the container infrastructure into a legacy folder and wrote down the plan the product still runs on today, which was that the agent runs headlessly inside Durable Objects. Containers and Sandboxes went generally available two weeks after that, on 13 April 2026, and we moved onto the Sandbox SDK later once it had the shape we wanted. So we are not claiming we predicted anything. We were watching a cost curve that scaled with signups instead of usage, we bet on Durable Objects before there was a tidy product name for what we were doing, and the platform kept arriving at the same answer we did. The team at camelAI hit the same wall and reached nearly the same conclusion https://camelai.com/blog/our-coding-agent-runs-in-a-cloudflare-durable-object-not-a-vm independently, which was a reassuring thing to read after the fact. What actually needs to be alive Here is the reframe that made the rest possible. "The agent needs a computer" quietly bundles two different things. There is the agent loop : the transcript, the tool routing, the model calls, the memory recall. And there is the machine : the thing that actually runs pdftotext , converts a spreadsheet, compiles a project. Only the second one needs Linux. The first one needs durable state and a socket, which is a much cheaper thing to want. Cloudflare happens to sell a primitive that is exactly durable state plus a socket, and once you split the two, the expensive half stops needing to be always on. The agent loop lives in a Durable Object Each agent gets one Durable Object, resolved by name. Sessions are rows inside it rather than objects of their own, and temporary subagents run as delegated sessions on the same parent object, so fanning out a job buys concurrency without buying instances. The transcript lives in that object's SQLite. We keep two things deliberately apart. One table is the append-only message log a user scrolls through. A separate record holds the compaction summary plus a watermark marking how far it has already consumed, and that second one is what actually reaches the model. Conflating them is how you end up paying to re-send an afternoon of conversation on every turn. The part that matters for the bill is hibernation. We use the WebSocket hibernation API, stash per-connection state on the socket itself, and let the runtime answer keepalives for us: when a client connects: accept the socket through the hibernation API attach { user, agent, session } to the socket itself once, when the object starts: register an automatic ping / pong reply the runtime answers keepalives without ever waking the object A user with a tab open all afternoon is not a running process. It is a socket the runtime is holding for us and a few rows in SQLite. The object wakes when something actually happens. Because it can be evicted at any moment, every transcript row is also archived to D1, and the object restores from that archive on start if it comes back empty. That is what makes it safe to let the thing die constantly, which is the entire trick. Eviction does produce one genuinely annoying edge case. Replayed events on reconnect can include a turn-started event with no matching completion, because the object was evicted mid-turn. So when a client reconnects and nothing is actually running, we send it a terminal idle status, purely so the client can never sit "thinking" forever. Linux shows up for the tool call, then leaves This is the section the title is about. When the agent calls the terminal tool, we resolve a sandbox: sandbox = getSandbox SANDBOX BINDING, instanceKeyFor user, workspace , { transport: rpc, enableDefaultSession: false, every exec is a fresh shell sleepAfter: idleWindowFor plan , } The instance key is scoped to the user, or to the workspace when we know it. Not per agent and not per session, so the number of live containers tracks active humans rather than agent count. sleepAfter is where the money is: function idleWindowFor plan : the per-plan command ceiling, clamped by a global cap seconds = min commandCeilingFor plan , GLOBAL COMMAND CAP command budget, plus a five minute buffer return minutes ceil seconds / 60 + 5 The global cap is 300 seconds today, so that resolves to a ten minute idle sleep on every plan. Pair that with active CPU billing on Cloudflare Containers, where you are charged for CPU actually consumed rather than wall-clock uptime, and an idle sandbox stops being a cost you manage. It is the difference between "we should reap idle machines" and "there is nothing to reap." One consequence is unglamorous and load-bearing. Disabling the default session means every exec is a fresh shell, so the working directory does not persist between tool calls. Agents hate this. We ended up writing a hint straight into the failure path, addressed to the model, explaining that each terminal call starts clean, that a relative path will resolve against the scratch tree rather than wherever it thinks it is, and that any directory change or exported variable from an earlier call is gone. Before each exec we kill leftover processes, then mount, then run. That order is not cosmetic. Killing after the mount drops the local bucket's inotify watch and can leave a stale read-only workspace behind, which we found out the way you would expect. The disk is R2, so there is no disk The durable workspace is an R2 bucket mounted into the container over s3fs, with allow other and an explicit uid and gid so the non-root user can actually write to it. This is the second half of the cost story and the less obvious one. Storage quotas of 100 MB, 1 GB, and 3 GB are quotas on bytes we are storing, not on volumes we are keeping spun up. Nobody's files are sitting on provisioned block storage waiting for them to come back. The files outlive the machine by construction, because they were never on the machine. The interesting bit is how we verify the mount. Checking that the directory exists is useless, because it passes on a stale read-only mount. So we make the non-root user actually write something: function workspaceIsWritable sandbox : probe = ".probe-" + now result = sandbox.exec as the non-root agent user: touch