RunR v0.5.7

Documentation

@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

RunR v0.5.7 · MIT License