Skip to content

The sandbox API

Nimbus.fromEnv uses a colocated DO binding. Nimbus.connect uses the authenticated remote API. Both return the same typed sandbox.

Terminal window
const box = nimbus.sandbox('job-123', {
profile: 'default', // sandbox profile from your NimbusConfig
tenant: 'acme', // namespaces the underlying DO: tenant:subject:id
subject: 'agent',
});
await box.ready(); // headless boot + profile preinstalls; memoized
await box.destroy({ reason: 'job done' });

Every call awaits ready(); call it directly to pay the boot cost first.

Run one command, run source code, or start a process.

Terminal window
const result = await box.exec('npm test', {
cwd: '/home/user/example-app', env: { CI: 'true' }, timeoutMs: 60_000, stdin: 'y\n',
});
// { command, exitCode, success, stdout, stderr, duration, timestamp }
await box.runCode('print(2 + 2)', { language: 'python', install: 'ifMissing' });
const proc = await box.startProcess('node --watch server.cjs');
// NimbusExecResult & { pid, process, ports }

exec holds the whole output until the command exits. For large or long-running output, use execStream: it returns stdout and stderr as bytes while the command writes them.

Terminal window
const run = await box.execStream('npm test', { timeoutMs: 600_000 });
for await (const chunk of run.output) {
await spill.write(chunk.stream, chunk.data); // chunk: { stream: 'stdout' | 'stderr', data: Uint8Array }
}
const { exitCode } = await run.exit; // settles after the last chunk

The stream applies backpressure. When the reader stops, the command waits at its next write, so unread output does not pile up in the sandbox: it holds about 64 KiB, plus what the RPC or HTTP transport has in flight. Cancelling run.output kills the command, and run.exit then rejects with the cancel reason. exec reads this same stream to the end.

Read and change files through the sandbox VFS.

Terminal window
await box.files.write('/home/user/example-app/server.cjs', code); // string | Uint8Array
const text = await box.files.read('/home/user/example-app/server.cjs');
const bytes = await box.files.readBytes('/home/user/data.bin');
const list = await box.files.list('/home/user'); // { name, type }[]
await box.files.stat(path); await box.files.mkdir(path);
await box.files.exists(path); await box.files.delete(path, { recursive: true });

Install allowed runtimes when a task needs them.

Terminal window
await box.runtimes.ensure(['python', 'clang']); // install if missing
await box.runtimes.list(); // { installed, available }

Nimbus checks runtimes.allow before the install RPC.

List processes and control their input, signals, size, and logs.

Terminal window
const procs = await box.processes.list(); // pid, command, state, exitCode…
await box.processes.kill(pid);
await box.processes.write(pid, 'input\n'); // stdin
await box.processes.signal(pid, 'SIGINT');
await box.processes.resize(pid, { columns: 120, rows: 32 });
const logs = await box.processes.logs(pid, { lines: 100 });

attach returns an async iterable for live input and output.

Terminal window
const attachment = box.processes.attach(pid);
await attachment.write('q');
for await (const chunk of attachment) {
// { seq, ts, stream: 'stdout' | 'stderr', data, binary? }
}

Every port gets a private preview URL. A server you start is durable: after a platform reset, the next request to its URL restarts it and waits for it.

Terminal window
await box.startProcess('npx vite --host --port 5173', { cwd: 'app' });
box.ports.url(5173); // https://5173--<id>.<suffix>/ owner only
await box.ports.list();

Sharing is explicit. apps.expose with visibility: 'public' mints a link anyone can open; the link is bound to the program (its command and working directory), not to the port.

Terminal window
const web = await box.apps.expose(5173, { visibility: 'public', name: 'web' });
web.url; // https://<capability>--web--<id>.<suffix>/
await box.apps.list(); // owner, name, port, pid, status, visibility, url
await box.apps.rotateLink('web'); // new link, old one revoked
await box.apps.expose('web', { visibility: 'scoped' }); // back to owner only
await box.apps.remove('web'); // stop it, release port and storage

A target is a port, a pid, a name, or an owner. startProcess(cmd, { restart: 'on-failure' }) also restarts a server after a crash; by default only a reset does.

Each (session, port) gets one host, <port>--<id>.<suffix>, where the suffix is the deployment’s NIMBUS_PREVIEW_HOST_SUFFIX binding; a named app also answers at <name>--<id>.<suffix>. The app mounts at that host’s root, so root-absolute paths resolve without rewriting. A deployment with no suffix bound falls back to /s/<id>/port/<port>/ on the endpoint, and public host links need the NIMBUS_PUBLIC_DIRECTORY binding (nimbusPublicDirectory: true in @nimbus-sh/config).

The private URL carries no credential. Where auth is enforced, GET /s/<id>/api/preview-url?port=<n> mints a single-use session:preview token for it (tokens & auth). A public link carries its capability in the hostname; rotating or removing the app revokes it.

Wrap the sandbox as a Proteus-style tool provider.

Terminal window
const provider = box.tools({ namespace: 'sandbox' });

It includes files, exec, processes, ports, apps (exposeApp, listApps), and runtimes.

Remote calls throw NimbusRemoteError with status, optional code, and the response body. The client validates wire payloads with zod, so a drifted server fails instead of returning malformed shapes.