Solari

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/sandbox
import { 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)
  timeoutMs: 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. 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
})
cpu on isolation:"shared" is capped at 4, not 8

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.

memMb IS enforced on isolation:"shared" — and the guest can't see the limit

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.

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)

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)

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 PNG

Structured 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 values
The PNG is always there; structured data depends on the template
Every matplotlib figure comes back as a base64 PNG on results[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:

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.

The signed URL is a bearer credential — treat it like one

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.

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 timeoutMs 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 timeoutMs, it defaults to 30 minutes.

A busy sandbox won't time out, and pausing frees up your plan
The idle timer resets whenever the sandbox is in use. A paused sandbox doesn't count against your plan's limit on running sandboxes; resuming it counts again.
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()

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 is not available with isolation:"shared"
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",
  timeoutMs: 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.

A parked sandbox still counts against your plan
Unlike a full pause, parking does not free your slot — a parked sandbox still counts against your plan's limit on running sandboxes, the same as an active one. Use it for short, frequent waits inside a session, not as a way to reduce what you're running.

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 timeoutMs — 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 timeoutMs.

A sensible starting point: 15 minutes

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.

Why these are opt-in rather than on by default
A sandbox that hasn't asked for any of this behaves exactly like the idle-timeout section above describes — a default value here would put every sandbox on a park/checkpoint path nobody asked for. Turn these on for workloads that actually wait or run long; leave them off otherwise.

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

await sbx.kill()
Prefer a web terminal?
Every sandbox has a browser terminal in the console under Sandboxes → Terminal. No SDK required to poke around.