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:
promigence project create myapp-prod
promigence project cap myapp-prod 1000
promigence keys create --project myapp-prod --name backend --access run --expires 90d
Language: bashStore 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:
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: bashNothing 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:
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: typescriptThe same in Python, for a threaded backend:
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: pythonThree 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:
promigence secrets set OPENAI_KEY --from-env OPENAI_API_KEY --host api.openai.com --no-raw
Language: bashGive 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-host | Managed, on Promigence | |
|---|---|---|
| Isolation for code nobody reviewed | You choose the boundary, harden it and keep it patched | No sandbox can see, reach or address another, yours or anyone else's |
| A sandbox per user | You build the pools and the scheduling | Sandbox.fork or Sandbox.create per user, found again by label |
| Idle users | Machines cost money unless you build pausing | An idle rule pauses a sandbox; paused time is not billed, and it is kept 30 days |
| Spend | You build the metering and the limits | A spend cap per sandbox, a monthly cap per project, a quote before any run |
| Network | You write the egress rules | Full outbound, none, or an outbound allow-list, with refused hosts recorded |
| Secrets | Your vault and your injection | Stored secrets given only to the commands that name them, kept out of snapshots |
| Where the data lives | Your own cloud account | Promigence's service; your own cloud account is a conversation with Promigence |
| Cost | Machines plus engineering time | Per second: $0.17 an hour for a small sandbox while it runs |
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#
- A sandbox backend for agent-builder and workflow platforms, read 17 Oct 2026
- Code interpreters that answer while your user is still looking, read 17 Oct 2026
- Limits and notices: spend caps, paused sandboxes, project caps, data out, read 17 Oct 2026
- Security: isolation, secrets, your own cloud account, read 17 Oct 2026
- Pricing: rates billed per second, plans and sandboxes at once, read 17 Oct 2026
- Quickstart: the build price, read 17 Oct 2026
- FAQ: auto-pause, network limits, secrets, running in your own cloud account, read 17 Oct 2026
- CLI reference:
project create,project cap,keys create --access,secrets set --host,sandbox network,sandbox kill, read 17 Oct 2026 - Information for AI assistants: secrets bound to a host, projects with a spend cap each, read 17 Oct 2026
- TypeScript SDK, and @promigence/sdk on npm, version 0.1.0-beta.7, read 17 Oct 2026
- promigence on PyPI: the Python SDK, version 0.1.0b7, read 17 Oct 2026
Updated 2026-10-17.