Solari

VMs

A VM is a full Linux computer with a graphical screen. Watch and control it right in your browser, or drive it from the SDK to automate anything a person could do on screen.

Create one

Start a VM from the console under VMs → New, or from the SDK. It's ready almost instantly, usually in about a second.

npm install @solarisdk/desktop
import { DesktopClient } from "@solarisdk/desktop"

const desktops = new DesktopClient({
  apiKey: process.env.SOLARI_API_KEY!, // your Solari API key
  baseUrl: "https://api.getsolari.com",
})

// Starts in about a second.
const desktop = await desktops.create({
  template: "default",
  resolution: "1280x720",
  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 15 min of no activity
  lifecycle: { onTimeout: "pause" },  // when idle: pause (resumable), or "kill"
})

console.log("watch it live:", desktop.streamUrl)

Choose its size

When you create a VM you can pick how many CPUs it has (from 1 to 8, default 2) and how much memory (up to 16 GB, default 2 GB). Ask for more than that and the create is rejected with a400 — it is not quietly reduced. Set cpu and memMb at create time. The size sticks: if you pause the VM and come back later, it resumes at the same size, so you don't need to set it again.

// A bigger VM for a heavier workload.
const big = await desktops.create({
  template: "default",
  cpu: 8,
  memMb: 16384,
})
Size survives pause/resume
The size you chose is remembered when a VM pauses, so it comes back at the same CPU and memory. You don't re-pass cpu/memMb on resume.

Drive it

connect() opens the connection so you can control the VM. From there you have the full toolkit: mouse and keyboard, screenshots, a shell, files, the clipboard, running processes, and launching apps. Wait for health() before your first action so the screen is up and ready.

await desktop.connect()

const health = await desktop.health()   // { ready, display, vnc }
if (!health.ready) throw new Error("VM not ready")

Mouse & keyboard

The VM responds to real mouse and keyboard input. Pass humanize: true for a natural, curved mouse path instead of an instant jump. This helps on sites that watch how the pointer moves.

// Mouse: move, click, press-drag, scroll.
await desktop.mouse.move(640, 360, { humanize: true })
await desktop.mouse.click(640, 360, { button: "left", humanize: true })
await desktop.mouse.down(640, 360, "left")
await desktop.mouse.move(720, 360, { humanize: true })
await desktop.mouse.up(720, 360, "left")          // drag
await desktop.mouse.scroll(640, 360, { humanize: true })

// Keyboard: type text, or press key combinations.
await desktop.keyboard.type("hello world")
await desktop.keyboard.press(["ctrl", "s"])       // chord
await desktop.keyboard.down(["shift"])
await desktop.keyboard.up(["shift"])

Screenshots

screenshot() returns an image of the screen as a Uint8Array. PNG by default, or JPEG with a quality setting for smaller images when you capture in a loop.

const png = await desktop.screenshot({ format: "png" })
const jpg = await desktop.screenshot({ format: "jpeg", quality: 70 })

Shell & files

exec runs a command and waits for it to finish; pass an onChunk callback to stream the output as it arrives. The fs tools read and write files directly.

// One-shot command.
const { exitCode, stdout, stderr } = await desktop.exec("uname", {
  args: ["-a"],
})

// Streamed command.
await desktop.execStream("apt-get",
  ({ stream, text }) => process.stdout.write(text),
  { args: ["install", "-y", "jq"] })

// Files: read, write, list.
await desktop.fs.write("/tmp/note.txt", "hello")
const text = await desktop.fs.readText("/tmp/note.txt")
const entries = await desktop.fs.list("/home")

Clipboard, apps & processes

// Copy and paste between your app and the VM.
await desktop.clipboard.set("paste me")
const clip = await desktop.clipboard.get()

// Launch an app by name; returns its pid.
const pid = await desktop.open("firefox", ["https://example.com"])

// Inspect and manage the process table.
const procs = await desktop.process.list()
await desktop.process.kill(pid)

// Resize the display on the fly.
await desktop.display.set(1920, 1080)

Watch it live

Every VM comes with a live view you can watch and control. streamUrl is ready when you create the VM; open it in a viewer (the console does this for you in the in-browser View tab) to watch and take over interactively.

const { streamUrl } = await desktop.stream.start()
// Open streamUrl in a viewer to watch and control the VM.

Re-attach later

Every VM has a stable id. connect(id) reconnects to a VM you started earlier, and resumes it if it was paused.

const box = await desktops.connect("dsk_abc123")
await box.connect()
await box.exec("whoami")

Idle timeout

A VM stays running as long as you're using it. Set timeoutMs at create time to say how long it can sit idle; every action and every open connection resets the clock. When that time passes with no activity, lifecycle decides what happens: onTimeout: "pause" (the default) pauses the VM and its state is saved, so the next connect() picks up right where you left off, while onTimeout: "kill" shuts it down. If you don't set timeoutMs, it defaults to 30 minutes.

A busy VM won't time out, and pausing frees up your plan
The idle timer resets whenever the VM is in use, so it won't disappear mid-task. A paused VM doesn't count against your plan's limit on running VMs; resuming it counts again.
// Change how long it can sit idle (ms).
await desktop.setTimeout(15 * 60 * 1000)

// Pause it now: state is saved, resumes on the next connect().
await desktop.pause()

Park, hibernate, and durable checkpoints

The idle timeout above is one lever. Three more exist for finer-grained control over a running VM — all of them opt-in: none of this happens unless you set the field. A VM you create with no lifecycle beyond onTimeout behaves exactly as described above, with nothing extra going on in the background.

const box = await desktops.create({
  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 agent spends most of its time waiting on something else — a tool call, an LLM response, a human looking at the screen — set parkAfterMs to deschedule the VM after that many milliseconds of no activity. A parked VM's CPUs are frozen, but its slot, network connection, and control/stream channel stay held, so the next touch 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, including the live VNC stream.

A parked VM still counts against your plan
Unlike a full pause, parking does not free your slot — a parked VM still counts against your plan's limit on running VMs, 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 VM 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 VM 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, a human stepping away from the desktop for a few minutes. The short parkAfterMs keeps those ordinary gaps on the light, instant path; only a VM that stays idle well past that escalates all the way to a full pause.

const box = await desktops.create({
  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 VM 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 (and the screen briefly freezing) instead of working.

Why these are opt-in rather than on by default
A VM that hasn't asked for any of this behaves exactly like the idle-timeout section above describes — a default value here would put every VM on a park/checkpoint path nobody asked for. Turn these on for agents 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 desktop 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 desktop 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

close() ends your connection; destroy(id) shuts the VM down for good. It can't be recovered.

desktop.close()
await desktops.destroy(desktop.sessionId)
Sandboxes are the screen-free sibling
Just need to run code, with no screen? See Sandboxes and its @solarisdk/sandbox package. Same idea and same lifecycle, just SandboxClient instead of DesktopClient. Want both from one client plus the solari CLI? Install @solarisdk/sdk and use solari.desktops / solari.sandboxes.