Skip to content

Guides

Give every user of your product their own sandbox

Run each user's code in a sandbox of their own: a key for your backend, a project with a monthly cap, a sandbox found again by the user's id, paused when idle and capped in dollars, and the self-host question answered.

Also as markdown: /docs/guides/sandbox-per-user.md

An agent product that runs code for its users needs a computer per user. A user's files should still be there on their next message, their code should never touch anyone else's, and nobody should be able to run up your bill. This guide sets that up on Promigence and answers the question that comes first: build it yourself, or use a managed service.

Should I self-host or use a managed sandbox service for my agent product?#

Use a managed sandbox service unless you have a hard reason to run your own, because isolating code nobody reviewed, starting sandboxes fast, pausing idle ones and capping spend add up to a product of their own. On Promigence each user gets a sandbox labelled with their id, found again on their next message, paused when idle and capped in dollars, all behind a key that can start work but cannot touch keys, secrets or billing. Self-host when your users' data must stay inside your own cloud account and a conversation with Promigence about running it there does not settle it.

Self-host or managed#

Self-hosting gives you full control and a long list of jobs. You choose an isolation boundary strong enough for code nobody reviewed and keep it patched, build the image pipeline, start sandboxes fast enough that a user is not left waiting, pause idle ones so they stop costing money, cap what one user can spend, write egress rules and keep secrets out of reach. Then someone on your team carries the pager for all of it.

It is the right call in three cases: the data cannot leave your own cloud account, you already run a platform team that operates isolation for untrusted code, or your sandboxes are busy around the clock at a scale where owned machines cost less than per-second billing. Everyone else ships sooner on a managed service and keeps the option to move later. The comparison is in At a glance.

1. A project, a cap and a key for your backend#

Keep production apart from staging with one project each, cap what production can spend in a month, and give your backend a key that can run sandboxes and cannot touch keys, secrets or billing:

Terminal
promigence project create myapp-prod
promigence project cap myapp-prod 1000
promigence keys create --project myapp-prod --name backend --access run --expires 90d
Language: bash

Store the key your backend gets as PROMIGENCE_API_KEY. A project that reaches its monthly cap refuses new work, and each sandbox's own spend cap bounds what is already running.

2. The environment users start from#

Every user's sandbox starts as a fresh copy of one snapshot. Put in it what your users' code needs, and a verify command that proves it imports:

Terminal
promigence snapshot create --image python:3.12 \
  --setup "pip install pandas matplotlib" \
  --verify "python -c 'import pandas, matplotlib'" \
  --alias user-env --wait --wait-timeout 10m
Language: bash

Nothing is stored if the verify command fails. The build is billed per second while it runs, whether it verifies or not, up to $0.17 at the default small size (quickstart), and every user's sandbox then starts from this snapshot with no further build charge.

3. One sandbox per user, found again on the next message#

Label each sandbox with the user's id when you create it. When the user writes again, list by that label and reuse what you find; a paused sandbox comes back as it was. The idle rule pauses a sandbox after five minutes with no request, and paused time is not billed. In TypeScript, run as an ES module:

TypeScript
import { PromigenceClient, Sandbox } from "@promigence/sdk";

const client = new PromigenceClient(); // PROMIGENCE_API_KEY: the backend key from step 1
const opening = new Map<string, Promise<Sandbox>>();

async function openSandbox(userId: string): Promise<Sandbox> {
  const [found] = await Sandbox.list({ client, labels: { user: userId }, state: ["ready", "running", "paused"], limit: 1 });
  if (found) {
    const sbx = await Sandbox.connect(found.sandbox_id, { client });
    if (found.state === "paused") await sbx.resume();
    return sbx;
  }
  const [sbx] = await Sandbox.fork("user-env", {
    client,
    count: 1,
    spendCap: 2,
    timeoutMs: 60 * 60_000,
    metadata: { user: userId },
    allowHosts: ["pypi.org", "files.pythonhosted.org"],
  });
  if (!sbx) throw new Error("no sandbox was started");
  await sbx.setAutoPause(300);
  return sbx;
}

// One lookup per user at a time, so two quick messages never start two sandboxes.
export function sandboxFor(userId: string): Promise<Sandbox> {
  let p = opening.get(userId);
  if (!p) {
    p = openSandbox(userId).finally(() => opening.delete(userId));
    opening.set(userId, p);
  }
  return p;
}

// The tool your agent calls: run the model's code in this user's sandbox, return what it printed.
export async function runCode(userId: string, code: string): Promise<string> {
  const sbx = await sandboxFor(userId);
  await sbx.files.write("/tmp/main.py", code);
  const r = await sbx.commands.run("python3 /tmp/main.py", { timeoutMs: 60_000, throwOnExit: false });
  return r.exitCode === 0 ? r.stdout : r.stdout + r.stderr;
}
Language: typescript

The same in Python, for a threaded backend:

Python
import threading
from typing import Dict

from promigence import Promigence, Sandbox

client = Promigence()  # PROMIGENCE_API_KEY: the backend key from step 1
locks: Dict[str, threading.Lock] = {}
locks_guard = threading.Lock()


def sandbox_for(user_id: str) -> Sandbox:
    with locks_guard:
        lock = locks.setdefault(user_id, threading.Lock())
    with lock:  # one lookup per user at a time
        found = Sandbox.list(client=client, labels={"user": user_id}, state=["ready", "running", "paused"], limit=1)
        if found:
            sbx = Sandbox.connect(found[0]["sandbox_id"], client=client)
            if found[0].get("state") == "paused":
                sbx.resume()
            return sbx
        sbx = Sandbox.create(
            "user-env", client=client, spend_cap=2, timeout_ms=60 * 60_000,
            metadata={"user": user_id}, allow_hosts=["pypi.org", "files.pythonhosted.org"],
        )
        sbx.set_auto_pause(300)
        return sbx


def run_code(user_id: str, code: str) -> str:
    sbx = sandbox_for(user_id)
    sbx.files.write("/tmp/main.py", code)
    result = sbx.commands.run("python3 /tmp/main.py", timeout_ms=60_000, throw_on_exit=False)
    return result.stdout if result.exit_code == 0 else result.stdout + result.stderr
Language: python

Three limits stack, and each stops a different kind of runaway. A command's timeoutMs ends one script that never finishes. The sandbox's spendCap holds what one user's sandbox may spend; a sandbox that reaches it is paused with its files kept, and resumes only under a higher cap. The project's monthly cap holds everything the backend starts.

The timeoutMs on the sandbox counts running time only, since time spent paused does not count, so a sandbox that sleeps between messages lasts for many sessions. Extend an active one with setTimeout (set_timeout in Python).

allowHosts puts the sandbox behind an outbound allow-list: here, the Python package index, so a user can still install a package. Refused connections are recorded, and promigence sandbox network SID lists them.

4. Keep your own keys out of users' sandboxes#

If code in a user's sandbox calls a model API on your account, do not hand it your key. Store the key once and bind it to the API's host; its value then stays out of the sandbox, and --no-raw makes sure it is never given to one:

Terminal
promigence secrets set OPENAI_KEY --from-env OPENAI_API_KEY --host api.openai.com --no-raw
Language: bash

Give it to a user's sandbox by name, with secrets: ["OPENAI_KEY"] on Sandbox.fork in TypeScript or secrets=["OPENAI_KEY"] on Sandbox.create in Python, and have the code send its requests to the address secrets set prints for the host: those requests are authenticated for you. A stored secret is never written into a snapshot you build.

5. Clean up#

When a user deletes their account, end their sandbox with kill() (or promigence sandbox kill SID). A paused sandbox is kept for 30 days, and resuming it starts the 30 days again; one that reaches 30 days is deleted with its files, and the organization's owner gets an email 7 days and 1 day before. Keep anything that must last longer in your own storage.

promigence project list shows what each project spent this month. A paused sandbox does not count against your plan's concurrency limit, so the idle rule decides how many users fit: 20 sandboxes at once on a new account, up to 100 on Pro, and up to 500 or 1,000 on Max.

At a glance#

Self-hostManaged, on Promigence
Isolation for code nobody reviewedYou choose the boundary, harden it and keep it patchedNo sandbox can see, reach or address another, yours or anyone else's
A sandbox per userYou build the pools and the schedulingSandbox.fork or Sandbox.create per user, found again by label
Idle usersMachines cost money unless you build pausingAn idle rule pauses a sandbox; paused time is not billed, and it is kept 30 days
SpendYou build the metering and the limitsA spend cap per sandbox, a monthly cap per project, a quote before any run
NetworkYou write the egress rulesFull outbound, none, or an outbound allow-list, with refused hosts recorded
SecretsYour vault and your injectionStored secrets given only to the commands that name them, kept out of snapshots
Where the data livesYour own cloud accountPromigence's service; your own cloud account is a conversation with Promigence
CostMachines plus engineering timePer second: $0.17 an hour for a small sandbox while it runs
A sandbox per userA user's message reaches your backend, which finds that user's sandbox by its label or starts one from the user-env snapshot. The code runs in the user's own sandbox, its output goes back through your agent to the user, and the sandbox pauses itself when the user goes idle.a messageuser idconnect, resume orforkcommands.runthe agent's answerYour usersYour backend: keywith run access,project with amonthly capFind the user'ssandbox by label, orstart one fromuser-envOne sandbox peruser, paused whenidleOutput of the user'scode
Your users, your backend, one labelled sandbox each

FAQ#

Should I self-host or use a managed sandbox service for my agent product?#

Use a managed service unless your users' data must stay in your own cloud account, you already run isolation for untrusted code, or your sandboxes are busy around the clock at a scale where owned machines cost less. Self-hosting means building isolation, fast starts, pausing, spend limits and egress rules yourself, then running them. With Promigence those features come with the service, and running it in your own cloud account is arranged by conversation, not self-serve.

How do I stop one user from running up my bill?#

Stack three limits: a timeout on each command, a spend cap on each user's sandbox, and a monthly cap on the project your backend's key belongs to. A sandbox that reaches its cap is paused with its files kept, and a project at its monthly cap refuses new work.

Can one user's sandbox reach another user's?#

No. No Promigence sandbox can see, reach or address another, yours or anyone else's. Give each user their own sandbox, as above, and never reuse one across users.

What happens to a user's files while they are away?#

An idle rule pauses the sandbox, and it keeps its files. Paused time is not billed, and a paused sandbox is kept for 30 days from its last pause. Resume it within that time and it comes back as it was.

Can Promigence run in my own cloud account?#

Not as a self-serve product today. If your data cannot leave your own cloud account, talk to Promigence about running it there.

Sources#

Updated 2026-10-17.

Get started

Leave your work email and we will get you started.

See our Privacy Policy for how we use your email.