Runtime

One sandbox per project: the architecture behind AI app builders

An AI app builder gives each project its own sandbox, pauses it between sessions, and keeps git, not the sandbox, as the record of the code.

On Runtime (withruntime.com) a paused project is running again 76 ms after the wake request and has run its next command 153 ms after it, on Runtime's servers, so 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, and the design question is what happens to those. This post lays out the parts of the architecture, the four places a project can live, the program that moves projects between them, and the bill for ten thousand projects.

Why does every project get its own sandbox?

Because the project's state is the product: its files, its installed packages, its running dev server and the half-finished build the model started. A shared server with a folder per user mixes customers on one kernel. A pool of containers reset per request throws the state away and 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 one with another's. The app builder use case shows the calls for a single project; this post is about 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.

  1. The browser shows the editor and the preview. It never talks to the sandbox API; it talks to your backend.
  2. 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.
  3. The project's sandbox holds the files, node_modules and the dev server. It runs what the model asked for and nothing else.
  4. 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 the browser sends, so a user can only ever reach 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?

In one of four tiers, each slower to reopen and cheaper to hold than the one before it.

Tier Where the code is Uses a sandbox slot Reopening takes Paid while there
Hot A running sandbox Yes Next command: 53 ms CPU used, plus memory
Warm A paused sandbox Yes Wake to next command: 153 ms Paused storage
Cool A disk snapshot No Create from snapshot: 440 ms, then 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 once, counting running and paused ones (pricing). That number is a starting point, and support raises it when you ask, but the tiers are worth having at any limit: they decide what a project costs while its owner is away.

Hot and warm need no code from you. A sandbox pauses itself after 60 seconds with nothing happening in it, and any request wakes it again. Set idlePauseSeconds to a few minutes for an editor, so a user who stops to read the preview does not pay a wake on their next keystroke.

How do you move projects between tiers?

With a small planner that runs every few minutes: keep enough slots free for new and returning projects, and move 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, SLOTS is your account's limit minus your busiest hour's hot projects, and the projects come 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 and deletes the sandbox; reopen asks for the project by name and builds it from whatever tier it is in. Both are idempotent, so a planner that crashes halfway can simply 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 sbx

Three 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 whole-machine one, 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 you can rebuild, and a repository is a history you cannot. Commit at the end of every agent turn with the user's prompt as the message, and push to a remote your backend owns. The push needs a token for that one repository; keep it 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 answers the existing one by name. Two agent loops writing the same files at once is the problem, and 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 once 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.

TextHot CPU:     300 × 30 days × 1 h × 0.3 vCPU × $0.025   = $67.50Hot memory:  300 × 30 days × 1 h × 4 GiB × $0.0075     = $270.00Warm:        50 × 1.5 GB × $0.08 a month              = $6.00Cool:        2,000 × 0.6 GB × $0.08 a month           = $96.00Cold:        7,950 projects in git                     = nothingTotal:                                                 $439.50Per project:                                           $0.0440

Most of the bill is memory during real editing, which 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, and keeping them all running idle would cost $225,000.00. The sandbox cost calculator runs the same sums on your own numbers, and a daily spending limit caps a bad day.

In short

  • Give every project its own sandbox, named after the project, so getOrCreate always 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, wakes 76 ms after the next request, and starts from a snapshot in 440 ms on Runtime's servers. Paused projects and snapshots pay $0.08 per decimal GB per 30-day month and no compute. Start with 100 free hours, no card: sign in or read get started.

Your first 100 hoursare on us.

  • No credit card
  • Eight sandboxes at once, 2 vCPU and 4 GiB each
  • Then prepaid credit from $10, no plan fee
Claim 100 hours free