@runr/sandbox
TypeScript client for the RunR Sandbox API: start isolated sandboxes on your own Kubernetes cluster, run commands and code in them, move files in and out, and remove them again. Every sandbox is a pod under the strongest isolation the region offers (Kata Containers or gVisor), without network access unless you allow it.
npm install @runr/sandbox
Node.js 18 or newer, ESM, no runtime dependencies (it uses the built-in fetch, and the built-in WebSocket where the runtime has one).
Configuration#
| Option | Environment variable | Meaning |
|---|---|---|
apiUrl |
RUNR_API_URL |
your RunR address, e.g. https://runr.example.com (/api is appended) |
apiKey |
RUNR_API_KEY |
an API token (Settings → API Tokens) |
tenantId |
RUNR_TENANT_ID |
the organization, on multi-tenant installations (sent as X-Tenant-ID) |
timeoutMs |
HTTP timeout per request (default 300000; streams have none) | |
fetch |
a fetch implementation (default: the global one) |
Quick start#
import { Sandbox } from '@runr/sandbox'
await using sandbox = await Sandbox.create({ runtime: 'python', ttlSeconds: 600 })
const result = await sandbox.exec('python3 --version')
console.log(result.exitCode, result.stdout)
// leaving the scope removes the sandbox (or call sandbox.close())
await using needs TypeScript 5.2+ ("lib": ["ESNext.Disposable"]); the package defines Symbol.asyncDispose on Node versions that lack it. Without it:
const sandbox = await Sandbox.create({ runtime: 'python' })
try {
// ...
} finally {
await sandbox.close()
}
Sandbox.create() options:
| Option | Example | Meaning |
|---|---|---|
runtime, version |
'python', '3.14' |
a runtime of the RunR catalog (default python, its default version) |
image |
'docker.io/library/alpine:3.21' |
any image instead of a runtime (it needs /bin/sh; tar for directory transfer) |
cpu, memory, disk |
'500m', 512, '1Gi' |
resources; memory and disk in MiB or as a quantity |
ttlSeconds, idleTimeout |
900, 300 |
lifetime and idle timeout in seconds |
network |
'none', ['pypi.org:443', '10.0.0.0/8'] |
no traffic (default) or an egress allowlist of hosts / CIDRs |
env |
{ API_KEY: '...' } |
environment of every command |
files |
{ '/workspace/main.py': 'print(1)' } |
files written before the sandbox is returned (string or Uint8Array) |
region, sandboxClass |
'eu-1', 'gvisor' |
where and under which isolation (auto, kata, kata-fc, gvisor, …) |
readOnlyRoot, pidsLimit |
true, 256 |
hardening options |
metadata |
{ agent: 'planner' } |
your labels, filterable in Sandbox.list({ metadata }) |
wait, startTimeoutMs |
true, 180000 |
wait until the sandbox runs (a sandbox that does not start in time is removed) |
Attach to an existing sandbox with Sandbox.connect(id) and list them with Sandbox.list({ status: 'active', metadata: { agent: 'planner' } }).
Commands#
await sandbox.exec('ls -la /workspace') // shell string
await sandbox.exec(['python3', '-c', 'print(42)']) // argv array
const result = await sandbox.exec('cat > in.txt', { stdin: 'data', timeout: 30, env: { A: '1' }, cwd: '/workspace' })
result.ok // exitCode === 0 and not timed out
result.exitCode // 137 when the timeout killed it
result.stdout // text; result.stdoutBytes for the raw bytes
Streaming#
for await (const event of sandbox.execStream('pip install --user requests')) {
if (event.type === 'stdout') process.stdout.write(event.data)
else if (event.type === 'stderr') process.stderr.write(event.data)
else if (event.type === 'exit') console.log('exit code', event.exitCode)
}
Output arrives as Server-Sent Events while the command runs. Pass signal (an AbortSignal) to stop reading early; breaking out of the loop closes the stream as well.
WebSocket exec#
execStream sends stdin up front. A program that reads its input while it runs — a REPL, a prompt, a consumer of a stream — runs over the exec WebSocket instead: execWs resolves once the command started, and its session sends stdin as you write it.
await using session = await sandbox.execWs(['python3', '-i'])
session.sendStdin('x = 6 * 7\n')
session.sendStdin('print(x)\n')
session.closeStdin() // the program reads end of file
for await (const event of session) {
if (event.type === 'stdout') process.stdout.write(event.data)
else if (event.type === 'stderr') process.stderr.write(event.data)
else console.log('exit code', event.exitCode)
}
tty: true runs the command on a terminal of cols × rows (default 80 × 24): programs see a TTY (colors, line editing, full-screen tools), stdout and stderr arrive together as stdout, and closeStdin() sends Ctrl+D. resize() follows the size of your own terminal:
await using session = await sandbox.execWs('bash', {
tty: true, cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24,
})
process.stdin.setRawMode?.(true)
process.stdin.on('data', (chunk) => session.sendStdin(chunk))
process.stdout.on('resize', () => session.resize(process.stdout.columns, process.stdout.rows))
for await (const event of session) {
if (event.type === 'stdout') process.stdout.write(event.data)
}
process.stdin.setRawMode?.(false)
process.stdin.pause()
const result = await session.wait() // exit code, killed, timedOut
process.exitCode = result.exitCode
| Session | |
|---|---|
sendStdin(data) |
bytes or UTF-8 text to the command's stdin (false once the command ended) |
closeStdin() |
end of input (on a terminal: Ctrl+D) |
resize(cols, rows) |
the terminal's size, 1–1000 each (tty sessions) |
kill() |
stop the command; it ends with exit code 137 and killed: true |
| iterating | stdout / stderr events, then one exit event (exitCode, timedOut, killed) |
wait() |
the result: exitCode, ok, killed, timedOut, tty and the output no loop read |
close() |
end the session; a command that still runs is stopped |
execWs takes the options of exec (stdin for input sent before the command starts, timeout, env, cwd, signal) plus tty, cols and rows; runCodeWs(code, { language }) does the same for code run by the language's interpreter. An abort of signal kills the command. The session is a one-time WebSocket on your RunR address (wss:// for https://); it uses the runtime's WebSocket (Node.js 22+, Bun, Deno) or the SDK's own client on Node.js 18 and 20 (transport: 'builtin' picks that one on any Node.js version). The command's own timeout applies; an error of the session rejects with a RunrSandboxError (reason exec_error for an error of the server, websocket_closed when the connection ends before the command did).
Running code#
import { writeFile } from 'node:fs/promises'
const result = await sandbox.runCode(`
import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [1, 4, 9])
plt.savefig("/output/plot.png")
print("done")
`, { language: 'python' })
console.log(result.stdout) // "done\n"
console.log(result.filesChanged) // [{ path: '/output/plot.png', size: ... }]
for (const output of result.outputs) {
await writeFile('plot.png', output.data) // images saved under /output
}
language is python, javascript, typescript, bash, sh, php, ruby, go or the id of a runtime that runs code through an exec command; by default the sandbox's own runtime. runCodeStream() streams the output and ends with a result event.
Files#
await sandbox.files.write('/workspace/data.csv', 'a,b\n1,2\n')
await sandbox.files.write('/workspace/run.sh', '#!/bin/sh\necho hi\n', { mode: 0o755 })
const bytes = await sandbox.files.read('/workspace/data.csv') // Uint8Array
const text = await sandbox.files.readText('/workspace/data.csv')
for (const entry of await sandbox.files.list('/workspace')) console.log(entry.type, entry.size, entry.path)
await sandbox.files.mkdir('/workspace/src')
await sandbox.files.remove('/workspace/src', { recursive: true })
// Directories travel as one tar archive
await sandbox.files.uploadDirectory({ 'main.py': 'print(1)\n', 'pkg/util.py': '' }, '/workspace/project')
const files = await sandbox.files.downloadDirectory('/workspace/project') // { 'main.py': Uint8Array, ... }
const tar = await sandbox.files.downloadArchive('/workspace/project')
await sandbox.files.uploadArchive(tar, '/workspace/copy')
createTar() and readTar() are exported for your own archives. Writable places are /workspace (the working directory), /output, /tmp and /home/sandbox.
Background processes#
const server = await sandbox.processes.start('python3 -m http.server 8000', { cwd: '/workspace' })
console.log(await sandbox.processes.list())
console.log((await sandbox.processes.logs(server.id)).stdout)
for await (const event of sandbox.processes.follow(server.id)) {
if (event.type !== 'exit' && event.type !== 'result') process.stdout.write(event.data)
}
await sandbox.processes.kill(server.id, 'TERM')
Lifetime#
await sandbox.setTimeout(1800) // live 30 more minutes (capped by the region's maximum)
await sandbox.keepalive() // reset the idle timer
await sandbox.pause(); await sandbox.resume()
await sandbox.setNetwork(['api.example.com:443'])
await sandbox.close() // remove now (idempotent)
Example: an AI agent code interpreter#
A tool handler for an LLM agent: one sandbox per conversation, the model's code runs in it, and the tool returns what the code printed plus any charts it drew.
import { Sandbox } from '@runr/sandbox'
export const runPythonTool = {
name: 'run_python',
description: 'Run Python code in an isolated sandbox. Save charts to /output/*.png.',
input_schema: {
type: 'object',
properties: { code: { type: 'string', description: 'Python source code' } },
required: ['code'],
},
}
export class CodeInterpreter implements AsyncDisposable {
private constructor(private readonly sandbox: Sandbox) {}
static async start(): Promise<CodeInterpreter> {
const sandbox = await Sandbox.create({
runtime: 'python',
ttlSeconds: 3600,
idleTimeout: 900,
network: ['pypi.org:443', 'files.pythonhosted.org:443'], // pip only
metadata: { purpose: 'code-interpreter' },
})
return new CodeInterpreter(sandbox)
}
/** Call this when the model invokes the tool; return the value as the tool result. */
async handle(input: { code: string }) {
const result = await this.sandbox.runCode(input.code, { language: 'python', timeout: 120 })
return {
exit_code: result.exitCode,
timed_out: result.timedOut,
stdout: result.stdout.slice(-20_000),
stderr: result.stderr.slice(-20_000),
files_changed: result.filesChanged.map((f) => f.path),
images: result.outputs.map((o) => ({ path: o.path, media_type: o.mime, data: o.dataBase64 })),
}
}
async [Symbol.asyncDispose]() {
await this.sandbox.close()
}
}
// In the agent loop
await using interpreter = await CodeInterpreter.start()
const observation = await interpreter.handle({ code: 'import math\nprint(math.factorial(20))' })
console.log(observation.stdout)
The code runs under the region's sandbox isolation, as a non-root user with no service-account token, inside the CPU, memory, disk and process limits you set; with the allowlist above it can reach the package index and nothing else.
Errors#
Every error is a RunrSandboxError (status, message, reason, body):
| Error | When |
|---|---|
AuthError |
401 / 403 — no token, an invalid one, or a token without the ability |
NotFoundError |
404 — unknown sandbox, process or file |
ConflictError |
409 — the sandbox is not running (ended or paused) |
QuotaError |
422 with a quota_* reason — e.g. quota_concurrent, quota_cpu, quota_seconds |
ValidationError |
other 422 answers — invalid options, unavailable region, runtime or sandbox class |
PayloadTooLargeError |
413 — a file above the upload / download limit |
RateLimitError |
429 — retryAfter holds the seconds to wait |
SandboxStartTimeoutError |
the sandbox did not run within startTimeoutMs |
Development#
npm install
npm run typecheck
npm test # compiles to dist-test/ and runs node:test against an in-process fake API
npm run build # dist/
License#
MIT