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 withruntimeThe 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);envis 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.stdingives 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: truethrowsCommandErroron 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.283Network 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.
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.