DocumentationAccount

JavaScript & TypeScript

Give your agent an isolated place to run code.

Install

withruntime is one client for every Runtime Cloud product. It has no dependencies, runs on Node 22 or later and Bun, and ships the runtime CLI.

Terminalnpm install withruntime

The client finds its key by itself: RUNTIME_API_KEY when it is set, and otherwise the connection this machine saved when it was connected (any npx withruntime command connects it, with one browser approval). On a server, put a key from https://withruntime.com/account/keys in RUNTIME_API_KEY from your secret manager. Never put it in source code, a URL, a browser bundle or a command-line argument. With no key anywhere, the first call fails with missing_api_key and says how to get one.

Hello, sandbox

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();const result = await sbx.exec("python3 -c 'print(6 * 7)'");console.log(result.exitCode, result.stdout);

Sandbox.create() takes no required arguments and returns once the sandbox is running. await using stops it when the block ends, even after an error. It needs Node 24, Bun, Deno or TypeScript; in plain JavaScript on Node 22, write const sbx = ... and call await sbx.stop() in a finally block.

With no arguments you get the free trial while it lasts, the default region, and 2 vCPU, 4 GiB of memory and a 4 GiB disk for up to 30 minutes. Every field is optional:

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime(); // RUNTIME_API_KEY, or this machine's connectionconst sbx = await runtime.sandboxes.create({  name: "tests-42",  labels: { team: "search", job: "42" },  vcpu: 2,  memoryMiB: 4096,  diskMiB: 8192,  timeoutSeconds: 900,  onLeaseEnd: "stop",  network: { internet: true, allow: ["pypi.org", "*.pythonhosted.org"] },});console.log(sbx.id, sbx.info.funding, sbx.info.expiresAt);await sbx.stop();

timeoutSeconds is how long the sandbox may run before its lease ends. At the end it pauses (the default) or stops, as onLeaseEnd says. network narrows what it can reach from its first start; see the sandbox environment.

Run commands

A string runs under bash -c. An array runs the program directly, with no shell, which is what you want for untrusted arguments.

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();await sbx.exec("mkdir -p app && echo 'print(1 + 1)' > app/main.py");const run = await sbx.exec(["python3", "main.py"], {  cwd: "/workspace/app",  env: { API_TOKEN: process.env.API_TOKEN ?? "" },  timeoutMs: 120_000,});if (run.exitCode !== 0) console.error(run.stderr);
  • env is how secrets reach a command. It is never echoed back, and journals record a hash, not the value. Never put a secret in the command line itself.
  • stdin gives the command input, then closes it.
  • The default timeout is 60 seconds; the maximum is 24 hours. A timeout is a result (timedOut: true, with the output so far), not an exception.
  • check: true throws CommandError on a non-zero exit, with the result on it.

Stream output as it happens with callbacks, or iterate the events:

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();await sbx.exec("for i in 1 2 3; do echo line $i; sleep 1; done", {  onStdout: (text) => process.stdout.write(text),  onStderr: (text) => process.stderr.write(text),});for await (const event of sbx.execStream("npm --version")) {  if (event.type === "stdout") process.stdout.write(event.data);  if (event.type === "exit") console.log("exit", event.exitCode);}

A stream that runs past the server's limit resumes by itself from the right byte, so no output is lost or repeated.

Background processes

spawn starts a server, a watcher or a REPL and returns at once. The process outlives your connection; get it back later with sbx.processes.get(id).

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();const server = await sbx.spawn("python3 -m http.server 8000", { cwd: "/workspace" });console.log(server.id, server.info.state);const repl = await sbx.spawn(["python3", "-i", "-q"], { stdin: "pipe" });await repl.write("print(21 * 2)\n");await repl.write("exit()\n", { eof: true });const done = await repl.wait();console.log(done.stdout);for (const p of await sbx.processes.list()) console.log(p.id, p.state, p.command);await server.kill("SIGTERM");

process.output() yields every event from the start, or from a cursor, until the process exits. Output is kept in the sandbox, so a reader that reconnects misses nothing.

An interactive terminal

terminal() opens a real terminal over a WebSocket: what you write is typed, and onData receives what the terminal prints, colours and all.

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();const term = await sbx.terminal({  cols: 120,  rows: 40,  onData: (bytes) => process.stdout.write(bytes),});term.write("echo hello from the terminal\n");term.resize(100, 30);term.write("exit\n");console.log("exit code", await term.exited);

npx withruntime sandbox shell <id> does the same from your own terminal.

Files

Paths are absolute. /workspace is the sandbox user's home; any path the user can reach works, and sudo reaches the rest.

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();await sbx.files.write("/workspace/data/input.csv", "a,b\n1,2\n");const text = await sbx.files.readText("/workspace/data/input.csv");const bytes = await sbx.files.read("/workspace/data/input.csv");console.log(text.length === bytes.length);console.log(await sbx.files.exists("/workspace/data/input.csv"));console.log(await sbx.files.stat("/workspace/data/input.csv"));for (const entry of await sbx.files.list("/workspace", { depth: 2 }))  console.log(entry.type, entry.size, entry.path);console.log(await sbx.files.glob("**/*.csv"));await sbx.files.mkdir("/workspace/out");await sbx.files.rename("/workspace/data/input.csv", "/workspace/out/input.csv");await sbx.files.remove("/workspace/data", { recursive: true });

write makes parent directories and replaces the file atomically. Large files go in parallel 1 MiB chunks, each checked by SHA-256, and resume after a dropped connection. There is no size limit beyond the disk.

Copy whole directories in one call. They travel as one compressed archive:

TypeScriptimport { mkdtemp, writeFile } from "node:fs/promises";import { tmpdir } from "node:os";import { join } from "node:path";import { Sandbox } from "withruntime";const project = await mkdtemp(join(tmpdir(), "project-"));await writeFile(join(project, "main.py"), "print('hi')\n");await using sbx = await Sandbox.create();await sbx.files.upload(project, "/workspace/project");await sbx.exec("cd project && python3 main.py > result.txt");await sbx.files.download("/workspace/project", join(project, "..", "project-out"));

Pause, wake, extend

TypeScriptimport { Sandbox } from "withruntime";const sbx = await Sandbox.create({ timeoutSeconds: 600 });await sbx.exec("echo state > /workspace/state.txt");await sbx.pause(); // memory and files are kept; compute billing stops// ... later, even from another process:const again = await Sandbox.connect(sbx.id);await again.wake({ timeoutSeconds: 1200 });await again.extend(600); // more time before the lease endsawait again.stop();

A paused sandbox keeps its memory, its processes and its files. Wake restores it on the same host. See pricing for what a paused sandbox costs and how long it is kept.

Find sandboxes again

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const page = await runtime.sandboxes.list({ labels: { team: "search" }, state: ["running"] });for await (const sbx of page) console.log(sbx.id, sbx.info.name, sbx.state);

Every list in every product returns a page: page.data, page.hasMore, await page.next() for the next page, await page.toArray(), and for await walks every item on every page. Filter by name, labels and state; stopped sandboxes are left out unless you pass includeStopped: true.

Errors and retries

Every failure is a typed error with a code, a message, a hint that says what to do, and a requestId to quote to support.

TypeScriptimport { NotFoundError, RuntimeError, Sandbox } from "withruntime";try {  await Sandbox.connect("00000000-0000-4000-8000-000000000000");} catch (error) {  if (error instanceof NotFoundError) console.log("no such sandbox");  else if (error instanceof RuntimeError) console.log(error.code, error.hint, error.requestId);  else throw error;}
Class When
AuthenticationError 401: the key is missing, wrong or revoked
PermissionDeniedError 403: the key or account may not do this
NotFoundError 404: no such resource in this account
ConflictError 409: the resource is in the wrong state, or the trial busy
InvalidRequestError 400 and 422: details names every wrong field
RateLimitError 429: slow down; retryAfterMs says how long
ServiceUnavailableError 503: capacity or a dependency; safe to retry
ConnectionError No answer at all
CommandError check: true and the command did not exit 0

Every write carries an idempotency key, made for you. Timeouts, dropped connections, 429 and 503 are retried with the same key and a growing delay, so a retried create never makes two sandboxes and a retried command never runs twice. Pass your own idempotencyKey to make a retry safe across process restarts.

Read-only keys and daily limits

An owner can make a read-only key, for monitoring and CI, and can set a daily spending limit on any key that spends, at API keys. A key reads both and can change neither. runtime.limits needs withruntime 0.3.1 or later:

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const { access, daily } = await runtime.limits.get();console.log(access); // "full", "read" or "selected"if (daily.remainingMicros !== null && BigInt(daily.remainingMicros) < 1_000_000n)  console.log("less than $1 left in this 24-hour window");

Past the limit, a create, wake, extension or renewal fails with a RuntimeError whose code is spending_limit_reached (HTTP 402). It is not retried: stop and tell the person you work for. A read-only key asking to change anything gets PermissionDeniedError. See security.

Custom images

Build an image once with your dependencies, then start every sandbox from it in the same second a plain one takes. Give exactly one source: a recipe of packages, a public image such as python:3.12-slim, or a single-stage dockerfile with the files its COPY lines read.

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const image = await runtime.images.build(  { name: "data", recipe: { pip: ["pandas"], apt: ["jq"] } },  { onLog: (line) => console.log(line.text) },);await using sbx = await runtime.sandboxes.create({ image: image.id });console.log((await sbx.exec("python3 -c 'import pandas; print(pandas.__version__)'")).stdout);

build waits until the image is ready and throws with the build's own error if it fails; images.create queues it and returns at once. An identical recipe is reused instantly. images.list(), images.get(id), images.logs(id) and images.delete(id) do the rest.

Volumes

A volume is a disk that outlives sandboxes. Attach it read-write to one sandbox at a time, or as a read-only snapshot copy to any number.

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const volume = await runtime.volumes.create({ sizeMiB: 10_240, name: "cache" });await using sbx = await runtime.sandboxes.create({  volumes: [{ volumeId: volume.id, path: "/data" }],});await sbx.exec("sudo chown runtime /data && echo kept > /data/note.txt");

A volume lives on one server and is not backed up off it; a sandbox that uses it is placed on that server.

Snapshots and forks

Forks and snapshots are paused while we fix an issue: for now fork, snapshot and a create naming snapshot answer 503 fork_unavailable. Your sandboxes are unaffected.

A fork is a copy of a sandbox as it is now: its files, its memory and its running processes, as a new sandbox of its own. Prepare a machine once, then try several things from exactly that point.

TypeScriptimport { Runtime, Sandbox } from "withruntime";const runtime = new Runtime();await using base = await Sandbox.create();await base.exec("pip install --quiet requests");const [a, b] = await base.fork({ count: 2 }); // both running, answered togetherawait Promise.all([a!.stop(), b!.stop()]);// Or keep the machine to start copies from later:const snapshot = await base.snapshot({ name: "with-requests", retentionDays: 7 });await using later = await runtime.sandboxes.create({ snapshot: snapshot.id });await runtime.snapshots.delete(snapshot.id);

A running sandbox is paused for the moment a snapshot or fork takes (usually well under a second), then woken; a paused one stays paused. Copies get the source's vCPUs, memory and disk, and run on its host. A snapshot lives on that host and is not copied off it; a sandbox with volumes cannot be snapshotted.

Code interpreter

A notebook-style Python or JavaScript session in the sandbox. Variables persist between runs; charts come back as PNG, data frames as tables.

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();await sbx.interpreter.run("import math\nx = math.pi");const cell = await sbx.interpreter.run("round(x * 2, 3)");console.log(cell.results[0]?.data["text/plain"]); // 6.283

Network rules

sbx.network.get(), sbx.network.set({ internet, allow, deny, connect }), sbx.network.off() and sbx.network.on(); see the sandbox environment for what each rule does.

Share a port

A preview gives one port of a sandbox an HTTPS address. It is private by default: a request needs the token, sent as the x-runtime-preview-token header, or the one-time urlWithToken link for a browser. WebSockets work.

TypeScriptimport { Sandbox } from "withruntime";await using sbx = await Sandbox.create();await sbx.spawn("python3 -m http.server 3000");const preview = await sbx.previews.create(3000);const page = await fetch(preview.url, {  headers: { "x-runtime-preview-token": preview.token! },});console.log(page.status);

Pass { visibility: "public" } for an address anyone can open, previews.rotate(port) to refuse every token issued so far, and previews.delete(port) to stop sharing. A preview's address is under runtimehost.com, the domain for everything sandboxes serve, kept apart from Runtime's own site.

A desktop

A Linux desktop in the sandbox, driven like a person would: open a page, click, type, press keys, take screenshots, and watch it live.

TypeScriptimport { writeFile } from "node:fs/promises";import { Sandbox } from "withruntime";await using sbx = await Sandbox.create();const { streamUrl } = await sbx.desktop.start({ width: 1280, height: 800 });console.log("watch it:", streamUrl);await sbx.desktop.open("https://example.com");await sbx.desktop.click(640, 400);await sbx.desktop.type("hello");await writeFile("screen.png", await sbx.desktop.screenshot());

The live view is a private preview of the desktop: streamUrl carries its one-time token, so open it in a browser and keep it to yourself.

Feedback and support

runtime.feedback.submit(...) tells the team what broke or is missing, and runtime.support.message(...) asks for help; see feedback and support.

Configuration

TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime({  apiKey: process.env.RUNTIME_API_KEY, // the default  maxRetries: 4,  timeoutMs: 120_000,});console.log((await runtime.me()).orgId);

RUNTIME_API_URL points the client at another API origin. Connections are kept alive and reused across calls.

Before 0.3.0 the package was @withruntime/cloud. That name still installs this package and exports the same classes, so older code and commands keep working.

Was this page right?