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-16 (default 2)
  memMb: 8192,                        // memory in MB, up to 65536 (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 16, default 2) and how much memory (memMb, from 2 GB up to 64 GB, default 2 GB) the sandbox has, same as a VM. The size sticks across snapshot, pause, and resume.

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)

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 parks 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()

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.