On Runtime (withruntime.com), a user who comes back finds the dev server where they left it. That covers the projects people are working on this week. An app builder with real traction has thousands more that nobody has opened in a month. The design question is what happens to those. This post covers the parts of the architecture, the four places a project can live, the program that moves projects between them, and the cost of ten thousand projects.
Why does every project get its own sandbox?
Because the project's state is the product. That state is its files, its installed packages, its running dev server, and the half-finished build that the model started. A shared server with a folder per user mixes customers on one kernel. A pool of containers that resets on each request throws the state away, then pays to rebuild it on every turn.
| Design | Isolation between users | State between sessions | Cost while nobody edits |
|---|---|---|---|
| One server, a folder per user | Process and file permission | Kept, but shared disk | The server, always |
| Container pool, reset per turn | Shared kernel | Rebuilt every turn | The pool, always |
| One microVM per project | Own kernel per project | Kept: files, memory, server | Storage only, paused |
The third row is the one that scales down to zero compute. A microVM is a small virtual machine with its own kernel. So one customer's generated code never shares a kernel with another customer's code. The app builder use case shows the calls for a single project. This post covers running ten thousand of them.
What runs where?
Four parts, and the rule is that the model and its keys never live inside the user's sandbox.
- The browser shows the editor and the preview. It never talks to the sandbox API; it talks to your backend.
- Your backend owns accounts, the projects table and the agent loop. It holds the model API key and calls the model, then turns each tool call into a sandbox call.
- The project's sandbox holds the files,
node_modulesand the dev server. It runs what the model asked for and nothing else. - A git remote you control holds every commit. The sandbox pushes to it at the end of each agent turn.
Your backend looks up the sandbox from the projects table, never from a value that the browser sends. So a user can reach only their own project. The table needs only a few columns: project id, owner, repository, the snapshot id if the project has one, the last edit time, and the tier.
Where can a project live?
Each project is in one of four tiers. Each tier is slower to reopen and cheaper to hold than the tier before it.
| Tier | Where the code is | Uses a sandbox slot | Reopening takes | Paid while there |
|---|---|---|---|---|
| Hot | A running sandbox | Yes | The next command runs at once | CPU used, plus memory |
| Warm | A paused sandbox | Yes | A wake, then the next command | Paused storage |
| Cool | A disk snapshot | No | A create from the snapshot, then the dev server | Snapshot storage |
| Cold | Your git remote | No | New sandbox, clone and install: seconds | Nothing on Runtime |
A paid account runs 100 sandboxes at the same time, counting running and paused ones (pricing). That number is a starting point. Support raises it when you ask. The tiers are worth having at any limit, because they decide what a project costs while its owner is away.
Hot and warm need no code from you. A sandbox pauses itself after
300 seconds with no activity, and any request wakes it again. For an
editor, set idlePauseSeconds to a few minutes. Then a user who stops to read
the preview does not pay for a wake on the next keystroke.
How do you move projects between tiers?
Use a small planner that runs every few minutes. It keeps enough slots free for new and returning projects. It moves the least recently edited paused projects down to snapshots. This one runs as written:
TypeScripttype Tier = "hot" | "warm" | "cool" | "cold";type Project = { id: string; tier: Tier; lastEdit: number };const DAY = 86_400_000;const now = Date.parse("2026-10-05T12:00:00Z");const ago = (days: number) => now - days * DAY;const SLOTS = 6; // sandboxes you allow at once, running or pausedconst FREE = 2; // kept open so a new or returning project never waitsconst SNAPSHOT_DAYS = 30; // the retention you set on each snapshotconst projects: Project[] = [ { id: "p-01", tier: "hot", lastEdit: ago(0) }, { id: "p-02", tier: "warm", lastEdit: ago(0.2) }, { id: "p-03", tier: "warm", lastEdit: ago(1) }, { id: "p-04", tier: "warm", lastEdit: ago(3) }, { id: "p-05", tier: "warm", lastEdit: ago(6) }, { id: "p-06", tier: "warm", lastEdit: ago(9) }, { id: "p-07", tier: "warm", lastEdit: ago(12) }, { id: "p-08", tier: "cool", lastEdit: ago(20) }, { id: "p-09", tier: "cool", lastEdit: ago(31) },];function plan(list: Project[]): string[] { const live = list.filter((p) => p.tier === "hot" || p.tier === "warm"); let over = live.length - (SLOTS - FREE); const oldestFirst = live.filter((p) => p.tier === "warm").sort((a, b) => a.lastEdit - b.lastEdit); const steps: string[] = []; for (const p of oldestFirst) { if (over-- <= 0) break; steps.push(`${p.id}: warm -> cool (snapshot the disk, delete the sandbox)`); } for (const p of list) if (p.tier === "cool" && now - p.lastEdit > SNAPSHOT_DAYS * DAY) steps.push(`${p.id}: cool -> cold (snapshot expired, reopen from git)`); return steps;}for (const step of plan(projects)) console.log(step);Pythonfrom datetime import datetime, timedelta, timezonenow = datetime(2026, 10, 5, 12, tzinfo=timezone.utc)SLOTS = 6 # sandboxes you allow at once, running or pausedFREE = 2 # kept open so a new or returning project never waitsSNAPSHOT_DAYS = 30 # the retention you set on each snapshotprojects = [ ("p-01", "hot", 0), ("p-02", "warm", 0.2), ("p-03", "warm", 1), ("p-04", "warm", 3), ("p-05", "warm", 6), ("p-06", "warm", 9), ("p-07", "warm", 12), ("p-08", "cool", 20), ("p-09", "cool", 31),]projects = [(pid, tier, now - timedelta(days=d)) for pid, tier, d in projects]def plan(rows): live = [r for r in rows if r[1] in ("hot", "warm")] over = len(live) - (SLOTS - FREE) steps = [] for pid, _, _ in sorted((r for r in live if r[1] == "warm"), key=lambda r: r[2]): if over <= 0: break over -= 1 steps.append(f"{pid}: warm -> cool (snapshot the disk, delete the sandbox)") for pid, tier, edited in rows: if tier == "cool" and now - edited > timedelta(days=SNAPSHOT_DAYS): steps.append(f"{pid}: cool -> cold (snapshot expired, reopen from git)") return stepsfor step in plan(projects): print(step)It prints three demotions, oldest first (p-07, p-06, p-05), and marks p-09
as cold. In production, set SLOTS to your account's limit minus the number of hot
projects in your busiest hour. Read the projects from your table. The
cool-to-cold move needs no call at all. The snapshot's own retention deletes
it, and the planner only updates the row.
What do demote and reopen look like?
Demote takes a disk snapshot of the paused sandbox, then deletes the sandbox. Reopen asks for the project by name and builds it from whatever tier the project is in. Both are idempotent, so a planner that crashes halfway can run again.
TypeScriptimport { NotFoundError, Runtime, Sandbox } from "withruntime";const runtime = new Runtime();const TEMPLATE = process.env.TEMPLATE_SNAPSHOT!; // framework installed, nothing elsetype Row = { id: string; repo: string; snapshotId: string | null };export async function demote(row: Row): Promise<Row> { const page = await runtime.sandboxes.list({ name: `project-${row.id}` }); const sbx = page.data[0]; if (!sbx) return row; // already demoted // A paused sandbox stays paused while its snapshot is taken. const snap = await sbx.snapshot({ mode: "disk", name: `project-${row.id}`, retentionDays: 30 }); await sbx.delete(); return { ...row, snapshotId: snap.id };}export async function reopen(row: Row): Promise<Sandbox> { const options = { idlePauseSeconds: 300, labels: { kind: "project", project: row.id } }; let sbx: Sandbox | undefined; if (row.snapshotId) { try { sbx = await Sandbox.getOrCreate(`project-${row.id}`, { ...options, snapshot: row.snapshotId, }); } catch (error) { if (!(error instanceof NotFoundError)) throw error; // the snapshot expired: go to git } } sbx ??= await Sandbox.getOrCreate(`project-${row.id}`, { ...options, snapshot: TEMPLATE }); if (sbx.info.reused) return sbx; // it was hot or warm: nothing to rebuild if (!(await sbx.files.exists("/workspace/app/.git"))) { await sbx.exec(["git", "clone", "--depth", "20", row.repo, "/workspace/app"], { check: true }); } await sbx.exec("npm install --prefer-offline", { cwd: "/workspace/app", check: true }); await sbx.spawn("npm run dev", { cwd: "/workspace/app" }); return sbx;}Pythonimport osfrom withruntime import NotFoundError, Runtime, Sandboxruntime = Runtime()TEMPLATE = os.environ["TEMPLATE_SNAPSHOT"] # framework installed, nothing elsedef demote(row: dict) -> dict: sbx = next(iter(runtime.sandboxes.list(name=f"project-{row['id']}")), None) if sbx is None: return row # already demoted # A paused sandbox stays paused while its snapshot is taken. snap = sbx.snapshot(mode="disk", name=f"project-{row['id']}", retention_days=30) sbx.delete() return {**row, "snapshot_id": snap["id"]}def reopen(row: dict) -> Sandbox: name = f"project-{row['id']}" options = {"idle_pause_seconds": 300, "labels": {"kind": "project", "project": row["id"]}} sbx = None if row.get("snapshot_id"): try: sbx = Sandbox.get_or_create(name, snapshot=row["snapshot_id"], **options) except NotFoundError: pass # the snapshot expired: go to git if sbx is None: sbx = Sandbox.get_or_create(name, snapshot=TEMPLATE, **options) if sbx.info.get("reused"): return sbx # it was hot or warm: nothing to rebuild if not sbx.files.exists("/workspace/app/.git"): sbx.exec(["git", "clone", "--depth", "20", row["repo"], "/workspace/app"], check=True) sbx.exec("npm install --prefer-offline", cwd="/workspace/app", check=True) sbx.spawn("npm run dev", cwd="/workspace/app") return sbxThree details carry the design. Demote never wakes the sandbox, because the
last agent turn already pushed its commit. A disk snapshot keeps only the
files. So it is smaller than a snapshot of the whole sandbox, and reopening
starts the dev server again. And the template already holds the framework's packages, so
npm install on a cold project mostly finds what it needs.
Why is git the record and not the sandbox?
Because a sandbox is a cache that you can rebuild, and a repository is a history that you cannot rebuild. Commit at the end of every agent turn, with the user's prompt as the message. Push to a remote that your backend owns. The push needs a token for that one repository. Keep the token as a secret the sandbox never sees.
That one habit buys a lot. Cold projects cost nothing on the sandbox side. Deleting an abandoned project's sandbox loses nothing. A user who wants their code takes the repository. And when the model breaks something, the last good turn is one commit back.
What happens when two tabs open the same project?
Both get the same sandbox, because getOrCreate returns the existing sandbox
that has that name. Two agent loops that write the same files at the same time cause
the problem. The fix lives in your backend: take a lock on the project row for the length
of an agent turn, queue a second prompt behind it, and let the second tab
watch the files change. To try two directions at the same time on purpose,
fork the sandbox and give each copy its
own preview.
What does a fleet of 10,000 projects cost?
About four and a half cents a project a month, with these assumptions: 300 projects edited each day for an hour on 2 vCPU and 4 GiB, with builds averaging 0.3 of a vCPU; 50 warm projects at 1.5 GB each; 2,000 cool snapshots at 0.6 GB; and the rest in git.
- Hot CPU
- 300 × 30 days × 1 h × 0.3 vCPU × $0.025
- $67.50
- Hot memory
- 300 × 30 days × 1 h × 4 GiB × $0.0075
- $270.00
- Warm
- 50 × 1.5 GB × $0.08 a month
- $6.00
- Cool
- 2,000 × 0.6 GB × $0.08 a month
- $96.00
- Cold
- 7,950 projects in git
- nothing
- Total
- $439.50
- Per project
- $0.0440
Most of the bill is memory during real editing. That is the part users pay you for. For contrast, holding all 10,000 as paused sandboxes at 1.5 GB would cost $1,200.00 a month. Keeping them all running and idle would cost $225,000.00. The sandbox cost calculator runs the same sums on your own numbers. A daily spending limit caps a bad day.
In short
- Give every project its own sandbox, named after the project, so
getOrCreatealways finds it. - Keep the model, its key and the agent loop in your backend; the sandbox only runs what the model asks.
- Hold projects in four tiers: running, paused, disk snapshot and git, with a planner moving the least recently edited down.
- Commit and push at the end of every turn, so any sandbox can be deleted without losing work.
- At realistic activity, 10,000 projects cost about $0.0440 each a month.
Run it on Runtime
Runtime costs 42% to 88% less than fourteen other sandbox providers for an agent that mostly waits on a model (compare costs). A Runtime sandbox pauses itself when nobody is editing. Paused projects and snapshots are billed at $0.08 per decimal GB per 30-day month, with no compute charge. Start with 100 free hours of a 1 GB machine every month, with no card needed: sign in or read get started.