Xeonr Developer Docs

Sandboxes

Long-lived interactive sessions — exec, persistent terminals, suspend and resume, preview URLs.

A sandbox is a pet: one pod, kept around, with a terminal and usually a durable workspace attached. It's what an AI agent session or a cloud dev environment runs on. Where a job is "run this and tell me what happened", a sandbox is "keep this alive and let me poke at it".

Creating one

const sandbox = await cp.sandboxes.create({
  environment,
  workspace: workspaceId,            // omit for ephemeral scratch
  cache: { policy: "write-back", writebackSec: 10 },
  egress: egress.none(),
  resources: { cpu: "2", memory: "4Gi" },
  idleSuspend: "15m",                // omit for never
  maxLifetime: "24h",                // hard kill switch
  label: "agent session — #general",
});

Running a command

A sandbox runs its environment's command as its workload — the entrypoint of the image, unless you say otherwise:

const sandbox = await cp.sandboxes.create({
  environment,
  argv: ["npm", "run", "dev"],   // omit to use the environment or image's own
  restart: "on_failure",         // "never" (default) | "on_failure" | "always"
});

The command is resolved once, at create, and persisted — so a resume runs the same thing rather than picking up whatever the image tag points at now. The order is:

  1. argv on the request
  2. the environment's default_argv
  3. the image's own ENTRYPOINT/CMD

Pass execOnly: true for a sandbox that runs nothing and exists only to be exec'd into. That is not the same as omitting argv: omitting means "resolve one", execOnly means "there is none".

This changed. Sandboxes previously ran a keepalive process instead of the image's entrypoint — not as a fallback, but as a silent replacement, because setting a container's command overrides ENTRYPOINT outright. A sandbox built on an image that starts a dev server started nothing at all. Now it starts the server. If you were relying on the old behaviour, set execOnly: true.

A crashing command cannot kill the sandbox. The workload runs as a child of a supervisor that holds PID 1, so when it exits — cleanly or not — the pod stays up, the workspace keeps syncing, and you can still exec in to find out why.

Two policies, two questions

restart governs the command; onExit governs the sandbox. They are separate because they answer separate questions, and because "retry my server, but if it's beyond saving stop paying for the sandbox" has no expression otherwise.

await cp.sandboxes.create({
  environment,
  restart: "on_failure",   // the command: rerun it if it failed
  onExit: "suspend",       // the sandbox: once we're done rerunning, suspend
});

restart — what happens to the command:

never (default)It runs once. However it ends, that's the end of it
on_failureA non-zero exit or a signal starts it again, with backoff, until a crash-loop ceiling. A clean exit is treated as done
alwaysStarted again however it ends, a clean exit included — so it never finally stops

on_failure and always differ only on a clean exit. For a server that never exits successfully they behave identically.

onExit — what happens to the sandbox, once the command is not going to run again:

suspend_on_success (default)Suspends after a clean exit; stays up after a failure
keepStays up either way, ready to exec into
suspendSuspends either way — the sandbox existed to run one thing

The default is asymmetric on purpose: a crashed run is the one you most need to look at, and a suspended sandbox cannot be looked at — its pod is gone, so there is no filesystem to inspect and no shell to open.

restart is evaluated first. onExit applies only once the command is not being restarted, so under restart: "always" it never applies at all. A run that exits within a few seconds of starting is never auto-suspended either, so a fast-exiting entrypoint does not make every resume look like it did nothing.

The run log

sandbox.logs() is what the workload printed — one continuous record across the sandbox's whole life, not just its current pod:

for await (const chunk of sandbox.logs({ follow: true })) {
  if (chunk.body.case === "marker") {
    console.log(`--- ${chunk.body.value.kind} ---`);   // RUN_EXITED, SUSPENDED, CRASHED…
  } else {
    process.stdout.write(chunk.body.value);
  }
}

It cannot come from the pod: a suspend deletes the pod, so pod logs would lose everything at the first suspend. The control plane collects and retains it instead.

Boundaries arrive as typed markers interleaved with the output in the same cursor order, rather than as text banners you would have to pattern-match:

MarkerWhen
RUN_STARTEDA run began — carries its argv and image digest
RUN_EXITEDIt ended — carries the exit code, or the signal that killed it
SUSPENDED / RESUMEDExplicitly, or by the idle timer
CRASHEDThe pod died — OOM, eviction, a lost node
TERMINATEDAn ending a resume cannot help with

run on each chunk counts from 1 per run, so a console can group output without interpreting markers. Pass the last offset you saw as fromOffset to resume without re-reading.

Retention is bounded — 16 MiB and 30 days per sandbox — but markers are exempt from pruning, so a log whose output has aged out still shows where its runs, crashes and suspends were. Without that exemption a pruned log would read as one uninterrupted run.

This is not a console session. A console session is a terminal you drive; the run log is what the sandbox's own command emitted, whether or not anyone was attached.

States are CREATING → RUNNING → SUSPENDED → RUNNING … and finally TERMINATED. WatchSandbox streams transitions with a human-readable detail ("idle-suspended", "oom", "exposed :8080").

GetSandbox reads back the whole envelope — resources, TTLs, cache policy, workload env, the base runtime_egress and every active grant — so a console can answer "why was that host refused?" without inspecting the pod.

Exec

Exec is bidi: the first message is the spec, then stdin frames; the server streams stdout, stderr, and a terminal exit.

const out = await sandbox.exec(["python", "main.py"], {
  cwd: "/workspace",
  timeout: "2m",
  idleTimeout: "30s",   // cut it loose on silence, not on the clock
});

console.log(out.stdout, out.stderr, out.exitCode, out.terminationName);

timeout is a backstop against a runaway process; idleTimeout is the one that catches a stuck one. A command still producing output is left alone however long it runs — an npm install legitimately takes a while — while one that has gone quiet is released, and whatever it printed first is returned rather than thrown away (out.idleTimedOut).

sandbox.shell("…") wraps a command in bash -lc, and sandbox.script("python3", code) feeds a multi-line script on stdin so quoting never arises.

argv only — never a shell string, unless you ask for a shell explicitly. The exit is status-derived, so an OOM reads as OOM_KILLED rather than a mysterious 137.

Exec ties the process to the stream that started it. Hang up and the process dies. When that's wrong — and for a browser terminal or an agent working across turns it usually is — use a console session instead.

Console sessions

A console session is a terminal owned by a daemon inside the sandbox's own container, so it outlives every client that attaches. The load-bearing idea is the cursor, not the stream: output is a byte-addressed ring with absolute offsets, so "resume after a dropped WebSocket" and "poll between agent turns" are the same request — everything after offset N.

const dev = await sandbox.processes.start("dev", "npm run dev", {
  cwd: "/workspace",
  cols: 120, rows: 32,
  idleTtl: "1h",
});

if (dev.exitCode !== undefined) console.error(dev.startupOutput);  // it died on startup

Starting the same name again reattaches rather than duplicating, so it is safe to repeat on every page load (dev.reattached). A freshly-started process is observed for a few seconds before being returned — the state captured the instant a session is created is always "started", so a process that dies at once on a wrong cwd or a missing command would otherwise report success.

Read it back later with dev.tail({ bytes: 4000 }), or sandbox.processes.get("dev") from a different turn entirely.

Two ways to drive it:

ShapeCallsFor
StreamingAttachConsole (bidi: spec, then stdin/resize; server streams output)Interactive terminals — set replay: true for a fresh terminal that has nothing on screen, or from_offset to resume exactly
UnaryWriteConsole / ReadConsole / ResizeConsoleAgents between turns, CLIs, anything that can't hold a stream. ReadConsole.wait_ms turns a read into a long poll

WriteConsole returns the ring tail_offset after the write, so a caller can read from there and see only that command's output. A reader that fell behind the retained window is told (truncated: true) rather than silently handed a gap.

Sessions are capped at 8 per sandbox by default — each shell and its children draw on the pod's pids limit, and an uncapped console exhausts the pid cgroup in a way that looks like a broken image. CloseConsoleSession escalates SIGTERM to SIGKILL, because an interactive shell ignores SIGTERM and a close that doesn't escalate silently does nothing.

A session's state is RUNNING, EXITED, or LOST — the last meaning the pod that hosted it is gone (suspended, rescheduled, evicted). The captured scrollback is still readable; the process isn't coming back. Sessions do not survive a suspend: there is no checkpoint and no process migration.

Suspend, resume, delete

await sandbox.suspend();                          // flush the workspace, delete the pod
await sandbox.resume();                           // reschedule on ANY node, remount
await sandbox.delete({ deleteWorkspace: false });

sandbox.keepAlive() resets the idle clock without running anything, and returns when the sandbox would next be suspended. sandbox.heartbeat() runs that on a timer paced from the server's own answer, rather than a guessed interval.

Suspend syncs the workspace and deletes the pod. Because state lives in object storage rather than a volume, resume schedules on any node — nothing is pinned. A suspended sandbox costs only the bytes its workspace occupies.

The workspace outlives the sandbox unless you ask otherwise: delete_workspace is opt-in, so results are still readable after the session is gone.

A suspend records its boundary in the run log before the pod is deleted, so an auto-suspend never erases the record of why it happened.

SyncWorkspace flushes the working copy now, returning counts and — importantly — conflicts: paths that changed both in the live container and in the durable store since the last sync. The container's copy wins (its edits are never dropped), but the remote change it overwrote would otherwise vanish silently, so the paths are surfaced for you to reconcile.

Preview URLs

ExposePort publishes a port the sandbox is serving on, at an isolated origin:

const exposed = await sandbox.expose(8080, { auth: "signed-url" });
// exposed.url → https://8080-<sandbox-id>.preview.containers.xnr.app

sandbox.exposeAll([8080, 5173]) returns port → URL for a set, skipping any already exposed.

Auth modeWho can reach it
AUTH_SIGNED_URLAnyone with the token; the click-through URL carries ?__cpt=…, which the proxy moves into a host-scoped cookie on first hit
AUTH_FORWARD_AUTHAnyone who can sign in to Xeonr Auth
AUTH_PUBLICAnyone

The preview domain is a separate origin from the console, with host-scoped cookies only, so hostile content served by a sandbox can never reach a console session. Exposing a port never reopens egress — the sandbox's policy still applies to outbound traffic.

A hit on a suspended sandbox resumes it, which is what makes scale-to-zero dev environments work.

One sharp edge: the agent owns ports 80, 443 and 53 inside the pod to capture traffic from clients that don't speak HTTP_PROXY. A sandbox that must serve on 80 or 443 gets capture disabled automatically — but only from the next pod start, since ports are only known from ExposePort. Exposing 80 or 443 on a live sandbox logs a warning and needs a suspend + resume before the workload can bind it. Such a sandbox keeps egress through the explicit proxy listener; it only loses the transparent path.

Live egress changes

GrantEgress opens an additive window on a running sandbox — the thing NetworkPolicy can never do per-pod-per-moment — and RevokeEgress removes one grant by id. Both take effect within seconds, with no pod restart. See Egress.

On this page