Sandboxes
A sandbox is a machine for running code and automation, with no screen: run commands, execute code that keeps its state, watch files, and snapshot and fork. It works just like a VM, minus the graphical display.
Create and connect
npm install @solarisdk/sandboximport { SandboxClient } from "@solarisdk/sandbox"
const sandboxes = new SandboxClient({
apiKey: process.env.SOLARI_API_KEY!, // your Solari API key
baseUrl: "https://api.getsolari.com",
})
const sbx = await sandboxes.create({
template: "base",
cpu: 4, // number of CPUs, 1-8 (default 2)
memMb: 8192, // memory in MB, up to 16384 (default 2048)
idleTimeoutMs: 15 * 60 * 1000, // pause after this much idle time
lifecycle: { onTimeout: "pause" }, // when idle: pause (default), or "kill"
})
await sbx.connect()Pick how many CPUs (cpu, from 1 to 8, default 2) and how much memory (memMb, up to 16 GB, default 2 GB) the sandbox has — above those the create is rejected with a400, not quietly reduced — same as a VM. The size sticks across snapshot, pause, and resume. A dedicated sandbox always has at least 2 GB of memory: a lower memMb is accepted, and the machine still starts at 2 GB. Both the 1–8 cpu range and the up-to-16 GB memMb range above are for isolation: "dedicated" (the default) and "hardened" — isolation: "shared" has its own, lower ceiling on both: cpu up to 4, memMb up to 2048 (default 512, not 2048); see below.
Isolation tiers
isolation picks how much of a machine you get. "dedicated" (the default) is a microVM of your own. "hardened" is the same, with all outbound network blocked. "shared" is cheaper and starts faster: your sandbox is a process tree inside a VM you share with your other "shared" sandboxes, isolated from them by namespaces and a cgroup rather than by a hypervisor. Cross-organization isolation is unaffected either way — you never share a VM with another org.
const sbx = await sandboxes.create({
template: "base",
isolation: "shared",
cpu: 4, // shared's own ceiling — see below, this is NOT the 1-8 range
memMb: 1024, // default 512 if you don't set it — lower than dedicated's 2048
})A cpu above 4 on a "shared" sandbox is rejected with a 400 naming the field: isolation:"shared" runs inside a 4 vCPU machine it shares with your other shared sandboxes, so cpu must be 4 or less. For more than 4, use isolation: "dedicated", which goes up to 8 on a machine of its own.
Within that range, cpu is a proportional share, not a hard ceiling: it sets your weight against the other contexts on the same VM. With idle neighbors, a context can burst past its own cpu number and use spare capacity on the machine. Under contention, you get your proportional share and no more — there is currently no way to get a hard per-context CPU ceiling on the shared tier.
Unlike cpu, memMb on a "shared" sandbox is a real, hard cap enforced by the host: exceed it and your process is killed, same as an out-of-memory dedicated VM.
The part that actually catches people: code running inside the sandbox cannot see this limit. /proc/meminfo and nproc report the whole shared VM's resources, not your context's memMb/cpu. Anything that sizes a worker pool, a cache, or a heap off those — a common pattern for "use N% of available memory" — will size itself against the machine, overcommit past your own memMb, and get killed with no warning from inside the process. If your workload sizes itself from the environment rather than a fixed number, set that number explicitly instead of reading it back from the guest.
On isolation: "shared", only commands.run and runCode work. File operations (files.*) and streaming commands (commands.start, PTY) are refused with an error whose code is NotAvailableOnSharedIsolation. Use isolation: "dedicated" when you need files or streamed output.
On a "shared" sandbox /usr, /etc, /var, /root, /opt and /srv are read-only, so apt and installs into the system Python fail. That is by design: those directories are shared with the other sandboxes on the VM. /work, /home and /tmp are yours and writable.
Install into /work instead: pip install --target /work/pylib <package> and set PYTHONPATH=/work/pylib, or npm install --cache /work/.npm <package> from a project directory under /work. Your commands run as root, whose home directory is /root — and that is read-only here, so anything that writes under ~ fails: pip install --user, npm's default cache, and tool caches under ~/.cache. (/home is writable, but it is not root's home.) python3 -m venv also fails, because the venv module's ensurepip is not installed. Or use isolation: "dedicated", where the whole filesystem is yours.
Run commands
commands.run runs a command to completion and returns { exitCode, stdout, stderr }; pass onStdout/onStderr to stream output live, and cwd/env to shape the environment.
const res = await sbx.commands.run("sh", {
args: ["-c", 'echo "$GREETING"'],
cwd: "/opt/app",
env: { GREETING: "hi" },
onStdout: (d) => process.stdout.write(d),
onStderr: (d) => process.stderr.write(d),
})
console.log("exit:", res.exitCode)timeoutMs is the deadline for one command, in milliseconds. It defaults to 600000 (10 minutes), the per-tier maximum for a one-shot command; pass 0 for no deadline. A command that hits its deadline does not throw: it returns with exitCode: -1 and timedOut: true, so check res.timedOut before trusting exitCode.
For a long-running process, commands.start returns a handle with streamed output, stdin, and kill.
const proc = await sbx.commands.start("cat")
proc.onData((c) => process.stdout.write(`[${c.stream}] ${c.data}`))
await proc.stdin("echo me\n")
await proc.kill()Interactive PTY
Need a real terminal (colours, full-screen apps, a shell prompt)? pty.create opens one you can write to, resize, and read from. It's what powers the console's web terminal.
const term = await sbx.pty.create({ cols: 100, rows: 30 })
term.onData((bytes) => process.stdout.write(bytes))
await term.write("ls -la\n")
await term.resize(120, 40)Named shells
A named shell is one persistent bash per name, so the working directory, exported variables, functions and background jobs carry over from one call to the next. Calls to one shell run one at a time, and the first call creates it (cwd and env apply then). It is an HTTP API:
# first call creates the shell "build"
curl -s -X POST "https://api.getsolari.com/sandboxes/$SANDBOX_ID_ENCODED/shells/build/exec" \
-H "Authorization: Bearer $SOLARI_API_KEY" -H "Content-Type: application/json" \
-d '{"cmd":"cd /opt/app && export MODE=ci"}'
# later calls see the same directory and variables
curl -s -X POST "https://api.getsolari.com/sandboxes/$SANDBOX_ID_ENCODED/shells/build/exec" \
-H "Authorization: Bearer $SOLARI_API_KEY" -H "Content-Type: application/json" \
-d '{"cmd":"pwd; echo $MODE"}'
# -> {"exitCode":0,"stdout":"/opt/app\nci\n","stderr":""}- The body takes
cmd(a shell script, up to 1 MiB), and optionallycwd,envandtimeoutMs(default 600000). The reply carriesexitCode,stdout,stderrand flags:created,timedOut,shellExited,stdoutTruncated,stderrTruncated. - A call that times out, or a script that runs
exit, ends that shell; the next call starts a fresh one. - Names are 1 to 64 characters of
A-Za-z0-9_.-. A sandbox can have at most 8 shells, and output is capped at 4 MiB per stream (the first 4 MiB come back and the flag is set). GET /sandboxes/:id/shellslists the live shells andDELETE /sandboxes/:id/shells/:nameends one (safe to repeat).- Named shells are not available on shared-isolation sandboxes (
409 ShellsUnsupported). A sandbox that was created from an older image answers501 ShellsUnavailable; create a new one.
The Python Runloop-compatible package exposes this as devbox.shell(name) (solari-runloop-compat). The TypeScript package does not support shell() yet and raises SolariCompatUnsupported.
Background commands and stdin
POST /sandboxes/:id/executions starts a command through a channel the gateway owns, so the process, its buffered output and its exit status survive your client disconnecting. Poll it with GET .../executions/:eid, long-poll with POST .../wait, signal with POST .../kill and follow output with .../stream.
To feed it input, POST .../executions/:eid/stdin with { "data": "...", "eof": true }. data is UTF-8 text, up to 1 MiB per call; eof: true closes the command's stdin after writing it, so a program that reads until end of input (cat, wc, a script reading stdin) can finish. Writing to an execution that has already ended answers 409 ExecutionNotRunning.
# start "wc -c", give it input and close stdin
EXEC=$(curl -s -X POST "https://api.getsolari.com/sandboxes/$SANDBOX_ID_ENCODED/executions" \
-H "Authorization: Bearer $SOLARI_API_KEY" -H "Content-Type: application/json" \
-d '{"cmd":"wc","args":["-c"]}' | jq -r .executionId)
curl -s -X POST "https://api.getsolari.com/sandboxes/$SANDBOX_ID_ENCODED/executions/$EXEC/stdin" \
-H "Authorization: Bearer $SOLARI_API_KEY" -H "Content-Type: application/json" \
-d '{"data":"hello","eof":true}'cmdis the program (no shell); put arguments inargs, or run{"cmd":"sh","args":["-c","…"]}. Optional:cwd,env,user.- Output is kept as a 1 MiB tail per stream in the gateway that started the run; the copy in the shared store that other gateways read keeps 256 KiB per stream (
stdoutTruncated/stderrTruncatedsay when the start was dropped); records are kept for at least 24 hours after exit. - At most 16 run at once per sandbox (
429 TooManyExecutions, retryable).wait,killandstdinwork on the gateway instance that started the run; another instance answers409 ExecutionOnOtherInstance, which you can retry.
The Runloop-compatible packages do not expose stdin close: closeStdIn raises SolariCompatUnsupported. Use the HTTP call above if you need it.
Run code
runCode runs a snippet and remembers its state between calls, so variables and imports you set stick around. Output comes back in results: text from stdout and stderr, plus richer output like matplotlib figures as a base64 PNG on results[i].png.
await sbx.runCode("import numpy as np; x = np.arange(10)", { language: "python" })
const out = await sbx.runCode("print(x.sum())", { language: "python" })
console.log(out.results, "error:", out.error ?? "none")
// Matplotlib figures come back as base64 PNG result items.
const fig = await sbx.runCode("import matplotlib.pyplot as plt; plt.plot(x); plt.show()")
const png = fig.results.find((r) => r.png)?.png // base64-encoded PNGStructured charts
Alongside the PNG, a matplotlib figure also comes back as structured data on results[i].chart, and every chart in a run is collected on out.charts for convenience. Instead of an image you can't read, you get the chart's type (line, scatter, bar, pie, or composite), its title and axis labels, and the underlying series. That makes it easy for feeding a figure straight to an agent or re-rendering it yourself.
const r = await sbx.runCode(`
import matplotlib.pyplot as plt
plt.bar(["a", "b", "c"], [3, 7, 5])
plt.title("Scores"); plt.xlabel("group"); plt.ylabel("count")
plt.show()
`, { language: "python" })
const chart = r.charts[0]
console.log(chart.type) // "bar"
console.log(chart.title) // "Scores"
console.log(chart.x?.ticks) // ["a", "b", "c"]
console.log(chart.y?.label) // "count"
console.log(chart.elements) // the raw series valuesresults[i].png. The structured chart / out.charts view is extracted by the guest and may be empty on some templates; when it is, fall back to the PNG.Version control
The git namespace wraps common Git operations so you don't have to shell out and parse porcelain yourself: clone, status, add, commit, push/pull, checkout, branches, and log all return typed results. Credentials are passed per-call and never touch a shell, so there is nothing to escape or inject.
await sbx.git.clone("https://github.com/psf/requests", {
path: "/work/requests",
depth: 1,
})
const status = await sbx.git.status("/work/requests") // cwd
console.log(status.branch, status.clean) // "main" true
await sbx.files.write("/work/requests/NOTE.md", "hi")
await sbx.git.add(["NOTE.md"], "/work/requests") // paths, cwd
const { hash } = await sbx.git.commit("Add a note", {
cwd: "/work/requests",
author: "Agent", email: "agent@example.com",
})
const log = await sbx.git.log({ cwd: "/work/requests", maxCount: 1 })
console.log(log[0].hash === hash, log[0].message) // true "Add a note"Branch and publish the same way. Pass a username / password (a personal-access token) to push or pull and the credential is spliced into the remote URL for that one invocation only. It is never written to the repo or global Git config, so nothing is left behind on the machine.
await sbx.git.checkout("feature/notes", {
cwd: "/work/requests",
create: true, // -b: start a new branch
})
await sbx.git.push({
cwd: "/work/requests",
branch: "feature/notes",
username: "agent",
password: process.env.GITHUB_TOKEN, // one-off auth, not persisted
})Files
The files surface reads and writes guest files directly, plus list, search, watch, and upload/download of larger blobs.
await sbx.files.write("/tmp/hello.txt", "world")
const text = await sbx.files.readText("/tmp/hello.txt")
const entries = await sbx.files.list("/tmp")
const hits = await sbx.files.search("/tmp", "world")
// Stream filesystem events until you call the returned stop().
const stop = await sbx.files.watch("/tmp", (ev) =>
console.log("fs:", ev.type, ev.path),
)
await stop()Expose a port
previewUrl(port) gives you a public URL for something the sandbox is serving on that port: a dev server, an API, anything. Available where a preview domain is set up.
const preview = await sbx.previewUrl(3000)
console.log("preview:", preview.url)The URL is signed: it carries a one-hour slr_token query parameter, and the response also returns that token separately as preview.token. Open preview.url as-is, or for other paths put the path before the query — or send the token as a header instead. Treat the URL like a credential and don't log it:
const { url, token } = await sbx.previewUrl(3000)
const origin = new URL(url).origin
// a specific path: path first, then the token
await fetch(`${origin}/api/health?slr_token=${token}`)
// or keep URLs clean and send the token as a header
await fetch(`${origin}/api/health`, {
headers: { "x-solari-preview-token": token },
})A 401 means the token was missing, altered, or older than an hour — call previewUrl() again for a fresh one. (If you're upgrading old code: pt_token, x-pinetree-preview-token, and the __pt_preview cookie were renamed on 2026-10-06 and are no longer accepted — a request using only an old name gets a distinct error naming the rename.) Anything that reaches the sandbox but finds nothing listening yet answers 425 Too Early; poll until it turns into your app.
The first request that authenticates via the query parameter also gets a host-scoped cookie carrying the same token, so a browser's follow-up requests for the page's own assets (which don't carry the query string) authenticate too. The cookie expires with the token — it is not a separate, longer-lived grant.
Anyone who has preview.url can use it, with no further check, until it expires: there is no way to scope it to a particular viewer. Don't paste it somewhere it will be logged or widely seen if what is behind the port is sensitive.
A port can also be opened with no token at all — minting that still requires your own API key and sandbox ownership, same as previewUrl() itself, so this is you choosing to drop the token requirement for READERS of your own port, not a way for anyone else to publish it. Once opened this way it is reachable by anyone who has or guesses the hostname, with no expiry — that is an explicit opt-in (not the default, and not yet in the published SDK's previewUrl() signature): call GET /sandboxes/:id/ports/:port?public=1 directly on a sandbox you own, and GET /sandboxes/:id/ports/:port?public=0 to revert it. Use it only for something that genuinely has no secrets behind it.
Don't rely on a published port staying reachable for as long as the sandbox runs: resolving the hostname back to a sandbox needs an index the gateway builds in memory per pod, and a tokenless request carries no credential that could trigger a durable lookup the way a signed-token request can. A gateway restart or redeploy can therefore make a published port start returning 404 while the sandbox itself is still running fine — publishing is not yet something you can depend on surviving a deploy.
Tunnels: every port, one switch
A tunnel turns on port access for all of a sandbox's ports at once, instead of minting a URL per port. It is authenticated by default.
curl -X POST https://api.getsolari.com/sandboxes/$SANDBOX_ID/tunnel \
-H "Authorization: Bearer $SOLARI_API_KEY" -H "Content-Type: application/json" \
-d '{"authMode": "authenticated", "wakeOnHttp": true}'
# {"urlTemplate":"https://a1b2c3d4e5f6-{port}.preview.getsolari.com",
# "authMode":"authenticated","wakeOnHttp":true,"keepAlive":false,"authToken":"..."}Put the port into {port} to get the URL for it. With authMode: "authenticated" every request must send authToken as the x-solari-preview-token header (or as ?slr_token= on the first request). One token works on every port of that sandbox and lasts an hour; GET /sandboxes/:id/tunnel returns a fresh one. With authMode: "open" no token is needed, so anyone who knows the hostname can reach the sandbox's ports: use it only for things that have nothing sensitive behind them.
wakeOnHttp: a request that carries a valid token wakes a paused or parked sandbox. Requests without one get401and wake nothing. It needsauthMode: "authenticated": anopentunnel withwakeOnHttpis refused with400, because anonymous traffic must not be able to start (and bill) your sandbox.keepAlive: accepted and returned, but it changes nothing today. Requests served through any tunnel already count as activity, so a sandbox that is being used through its tunnel is not auto-paused.- Calling
POSTagain reconfigures the tunnel (it is idempotent). Not available on shared-context sandboxes.
DELETE /sandboxes/:id/tunnel turns the tunnel off and revokes every token it issued; creating a new tunnel later issues fresh tokens, and the old ones stay dead. Revoking a tunnel takes effect within a few seconds, not instantly: for a short moment after the call, a gateway instance that served the tunnel moments earlier can still honour an old token.
Account secrets
Store API keys and passwords once and hand them to sandboxes by name, without ever putting the value in your code or your logs. Secrets are stored encrypted, belong to your organization, and are write-only: no call ever returns a value. The value lives in the sandbox's environment while it runs, so it is also part of that sandbox's saved state when you pause it or take a snapshot: anyone who can restore that snapshot can read it.
# create (409 if the name exists), replace, list, delete
curl -X POST https://api.getsolari.com/secrets -H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" -d '{"name":"STRIPE_KEY","value":"sk_live_..."}'
curl -X PUT https://api.getsolari.com/secrets/STRIPE_KEY -H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" -d '{"value":"sk_live_new..."}'
curl https://api.getsolari.com/secrets -H "Authorization: Bearer $SOLARI_API_KEY"
curl -X DELETE https://api.getsolari.com/secrets/STRIPE_KEY -H "Authorization: Bearer $SOLARI_API_KEY"
# use it: the variable name on the left, the secret name on the right
curl -X POST https://api.getsolari.com/sandboxes -H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" -d '{"kind":"sandbox","secrets":{"STRIPE_API_KEY":"STRIPE_KEY"}}'- Names start with a letter and use letters, digits,
_,.or-(up to 64 characters). A value is 1 byte to 16 KiB. Up to 200 secrets per organization and 20 per sandbox. - The value is copied into the sandbox's environment when it is created. Changing or deleting a secret later does not change sandboxes that already received it; only sandboxes created afterwards see the new value.
- A name that does not exist is a
400 SecretNotFound. The variable namesPATH,HOME,LD_*and similar system names are refused, and so is a name you also set inenvs. - Anything running inside the sandbox can read its own environment, so give a sandbox only the secrets it needs.
What a sandbox ran on
A template is a name that can be rebuilt; a golden is the exact build behind it. Every sandbox view (GET /sandboxes/:id, the list, and the response to create) names both, so you can tell whether two runs used the same bits:
{
"template": "base",
"golden": {
"imageKey": "d0f718435a7b9962…",
"template": "base",
"rootfsSha256": "…",
"kernelSha256": "…",
"chVersion": "cloud-hypervisor_v43.0.0"
}
}Two sandboxes with the same imageKey ran a byte-identical root filesystem, kernel and hypervisor. The golden is recorded each time the sandbox is placed on a host (create, resume, restore, start, revert), so a resume onto a host with a newer build shows the new one. The field is left out when the host did not report one, for example a sandbox that was last placed before the field existed; it fills in the next time the sandbox is placed. Nothing else about the build (source revision, kernel command line) is exposed. Snapshots carry the golden their sandbox ran on when they were taken; see Snapshots. The TypeScript and Python SDKs type it as GoldenInfo.
Snapshots
Save a prepared sandbox and start ready-to-go copies from it, or reset one back to that saved state between runs. See Snapshots for the full flow.
const snapId = await sbx.snapshot("after-setup")
const fork = await sandboxes.create({ template: "base", fromSnapshot: snapId })Idle timeout
Like VMs, a sandbox stays running as long as you're using it. Set idleTimeoutMs to say how long it can sit idle; every action and open connection resets the clock. When that time passes with no activity, lifecycle decides what happens: onTimeout: "pause" (the default) saves its state and pauses it, while onTimeout: "kill" shuts it down. If you don't set idleTimeoutMs, a sandbox defaults to 2 hours, so quiet batch jobs aren't paused underneath you. (VMs default to 30 minutes.)
await sbx.setTimeout(15 * 60 * 1000) // change how long it can sit idle (ms)
await sbx.pause() // pause it, state is saved, resumes on connect()If the sandbox powers itself off
If code inside a sandbox powers the machine off (for example by running poweroff or shutdown), the sandbox becomes stopped. Billing stops when the power-off is detected, and Solari keeps the sandbox's disk. Memory and running processes are gone, and every request to a stopped sandbox other than start returns 409 SandboxStopped. A reboot is different: if code inside the sandbox reboots the machine, the sandbox stays and keeps running.
sbx.pause()) before you stop using it.Restart a stopped sandbox
Call start() to boot a stopped sandbox again. It is a cold boot from the kept disk: your files are there, but the processes that were running are not, so start them again. Starting takes a concurrency slot, opens a new billing segment, and gives the handle a fresh control channel.
Stopped vs. paused. Pause saves memory and processes and resume() brings the sandbox back exactly as it was. A stopped sandbox powered itself off, so only its disk survives and start() boots it fresh. Calling start() on a paused sandbox, or resume() on a stopped one, is refused with 409.
await sbx.start() // cold boot from the kept disk
await sbx.commands.run("ls", { args: ["/work"] }) // files are still thereawait sbx.start() # cold boot from the kept disk
await sbx.commands.run("ls", args=["/work"]) # files are still thereStarting is safe to repeat: a sandbox that is already running answers success. If the host cannot start the sandbox, it stays stopped with its disk, and you can call start() again. See POST /sandboxes/:id/start for the error codes.
Park, hibernate, and durable checkpoints
The idle timeout above is one lever. Three more exist for finer-grained control over a running sandbox — all of them opt-in: none of this happens unless you set the field. A sandbox you create with no lifecycle beyond onTimeout behaves exactly as described above, with nothing extra going on in the background.
parkAfterMs acts on the whole VM, and a shared sandbox runs alongside other contexts on that same VM: parking would freeze every sibling context too. Setting it on a shared sandbox is rejected with a 400 naming the field. It currently requires isolation: "dedicated" (the default) or "hardened".hibernateAfterParkedMs and checkpointEveryMs are honored on shared — both write their snapshot pointer onto every sibling context, so they no longer strand a sibling’s data the way an unqualified whole-VM action would.
const sbx = await sandboxes.create({
template: "base",
idleTimeoutMs: 15 * 60 * 1000,
lifecycle: {
onTimeout: "pause",
parkAfterMs: 5_000, // deschedule after 5s idle (min 2_000)
hibernateAfterParkedMs: 60_000, // escalate to a full pause after 1 min parked
checkpointEveryMs: 5 * 60_000, // durable checkpoint every 5 min (min 60_000)
},
})parkAfterMs — instant descheduling for short waits
If your workload spends most of its time waiting on something else — a tool call, an LLM response, a webhook — set parkAfterMs to deschedule the sandbox after that many milliseconds of no activity. A parked sandbox's CPUs are frozen, but its slot, network connection, and control channel stay held, so the next request wakes it with nothing to restore. This is a different, lighter mechanism than the full pause above, not a faster version of it: pausing frees the slot and saves state to durable storage; parking freezes the CPUs in place and keeps everything resident.
parkAfterMs is floored at 2 seconds (a lower value is raised to it, so a caller can't ask for a park/unpark storm) and capped at your idleTimeoutMs — parking after the session would already have paused is a no-op, so the value is clamped rather than silently ignored.
hibernateAfterParkedMs — escalate a long park to a full pause
If a parked sandbox stays parked this long with still no activity, the gateway escalates it to a full pause — state saved, slot freed — instead of holding the slot indefinitely on the chance activity resumes. Use it when waits are usually short but can occasionally run long, and you'd rather free the slot than keep paying for it during the long tail. Same floor as parkAfterMs, capped at idleTimeoutMs.
If you are not sure what to set, pair a short parkAfterMs with hibernateAfterParkedMs: 900_000 (15 minutes). It is short enough to meaningfully free the slot on a sandbox that has genuinely been abandoned, and long enough that it will not escalate one that is mid-way through an ordinary gap — a slow tool call, a person reading the screen, an agent loop between turns. The short parkAfterMs keeps those ordinary gaps on the light, instant path; only a sandbox that stays idle well past that escalates all the way to a full pause.
const sbx = await sandboxes.create({
template: "base",
lifecycle: {
onTimeout: "pause",
parkAfterMs: 5_000, // deschedule almost immediately on idle
hibernateAfterParkedMs: 15 * 60_000, // free the slot after 15 minutes parked
},
})This is a starting point, not a measured optimum — we do not yet have a large enough sample of real idle-to-touch gaps on this product to derive one. Tune it against your own workload: raise it if your sessions idle for minutes at a time as a matter of course, lower it if every minute of a parked-but-idle slot is expensive for you.
checkpointEveryMs — durable continuations for long-running sessions
Take a durable checkpoint on this interval while the sandbox runs, so an unexpected host issue costs at most one interval of progress rather than the whole session. Off by default — nothing checkpoints a session unless you set this field. Floored at 60 seconds: a checkpoint briefly pauses the guest for the length of the snapshot, so too short an interval spends the session stuttering instead of working.
On wake latency: park’s handoff has no cold-restore step — the guest's memory never leaves host RAM — while a full pause restores from durable storage and is the slower of the two. Measured on staging across five trials, all resuming on the same host the session was paused on (resume defaults to the originating host; it only rebuilds on a different one if that host has become unreachable): once a pause had actually finished shipping its snapshot, resume took about 1.3 seconds. But pause and resume share a per-sandbox lock, so calling resume while a pause is still in flight queues behind it rather than failing or racing — resumes fired a few seconds after the pause call, before the snapshot had finished, took 16–20 seconds in our trials. If your workload calls resume shortly after pause, budget for the slower number; if pause has had time to settle first, expect the faster one. Either way, resume is safe to call as soon as you want it — it will simply wait its turn. We haven't yet measured the cross-host case (the host that held your paused session is gone or unreachable, so resume rebuilds from the durable snapshot elsewhere) — expect that path to be slower, and treat it as unmeasured rather than assuming it matches the number above.
Tear down
Release a sandbox with kill(), or declare it with await using (compiled TypeScript 5.2+; the syntax runs uncompiled only on Node 24) so it is killed when the scope ends, even if something throws. Calling kill() on a sandbox that is already deleted is not an error. A process that ends with a sandbox it never killed or closed prints a one-line reminder at exit.
await sbx.kill()
// or, scoped:
{
await using sbx = await sandboxes.create({ template: "base" })
await sbx.commands.run("echo", { args: ["hi"] })
} // killed hereIn Python, use async with:
async with await sandboxes.create(template="base") as sbx:
await sbx.commands.run("echo", args=["hi"])
# killed hereComing from another sandbox vendor?
Compatibility packages let code written for another vendor's SDK run on Solari sandboxes by changing the import:
Browser code written for Browserbase has its own page: Migrate from Browserbase.
