# @n8n/sandbox-client Client for the n8n sandbox service API. ## Install ```sh pnpm add @n8n/sandbox-client ``` ## Usage ```ts import { SandboxClient } from '@n8n/sandbox-client'; const client = new SandboxClient({ baseUrl: 'http://localhost:8080', apiKey: 'your-api-key', }); ``` ### Retries (`retry` option) By default the client retries transient failures: 3 extra attempts (four tries total), backoff with jitter, and HTTP statuses `429` and `503` (plus transport errors, represented as status `0`). `502` is not in the default retry set — the service uses it when repeating the same request is unlikely to help. The service answers `429` when a tenant or a sandbox has too many requests in progress at once (see [API.md](https://github.com/n8n-io/n8n-sandbox-service/blob/main/docs/API.md#http-429--too-many-requests-in-progress)). It refuses such a request before it reaches the sandbox, so every method is retried on a `429`. - Turn off retries: `retry: { attempts: 0 }`. - Tune backoff: `retry: { attempts: 5, baseDelayMs: 100, maxDelayMs: 30_000, jitter: false }`. - Also retry other statuses (only if you accept the risk): `retry: { retryOnStatuses: [429, 502, 503] }`. - Idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) use this policy automatically. - `POST` is retried only where the service makes it idempotent: `createSandbox` with an explicit `id`, and `exec` after a `429`. Other `POST`s, such as appending to a file, surface a `429` to the caller. - `createSandbox` without an `id` is also retried on a `503` whose body has `reason: "no_capacity"`: no runner was eligible or had room, and nothing was created (see [API.md](https://github.com/n8n-io/n8n-sandbox-service/blob/main/docs/API.md#post-sandboxes)). Once the retries run out, it throws a `SandboxServiceError` with that `reason`. `exec` still has its own stream resume loop (`exec_id`, `POST` then `GET` follow): after a `503`, a transport error or a stream that ends early, it posts the same `exec_id` again, or resumes with `GET` once events have arrived. Constructor `retry` applies to each underlying HTTP call: `GET` resume lines benefit from the default policy, and an exec `POST` refused with `429` is re-posted with the same `exec_id`, which starts the command, or follows it if an earlier attempt started it. When those retries run out on a `429` while the command may be running, `exec` cancels it in the background and throws. It does not replace the exec event/state machine. ### Sandbox lifecycle `createSandbox` requires an `egress` mode: `'public'` (the internet, minus private ranges) or `'none'` (no outbound connection, DNS included). It is fixed at creation and echoed on every sandbox record. Reconnecting by `id` with a different mode is refused by the API (`409`). If the API returns a sandbox that is not in the requested mode (it predates egress modes and ignored the field), `createSandbox` throws `EgressMismatchError` with its `sandboxId`. ```ts // Create a sandbox const sandbox = await client.createSandbox({ egress: 'public' }); console.log(sandbox.id); // UUID // Create or reconnect to a deterministic sandbox (pass the egress it was created with) const stableSandbox = await client.createSandbox({ id: '550e8400-e29b-41d4-a716-446655440000', egress: 'public', }); // No egress const sealed = await client.createSandbox({ egress: 'none' }); console.log(sealed.egress); // "none" // Ephemeral: deleted instead of stopped when idle (see API.md, POST /sandboxes) const scratch = await client.createSandbox({ ephemeral: true, egress: 'public' }); console.log(scratch.ephemeral); // true // Owner: the tenant id the key belongs to, or "__admin__" with an admin key. // Undefined against a service from before the field existed. console.log(scratch.tenantId); // Get sandbox info const info = await client.getSandbox(sandbox.id); // Delete sandbox await client.deleteSandbox(sandbox.id); ``` ### Execute commands ```ts const result = await client.exec(sandbox.id, { command: 'echo hello world', env: { NODE_ENV: 'production' }, workdir: '/home/user', timeoutMs: 30_000, }); console.log(result.stdout); // "hello world\n" console.log(result.exitCode); // 0 console.log(result.success); // true console.log(result.executionTimeMs); // 42 ``` Stream output as it arrives: ```ts const result = await client.exec(sandbox.id, { command: 'npm install', onStdout: (data) => process.stdout.write(data), onStderr: (data) => process.stderr.write(data), }); ``` Cancel a running command: ```ts const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); const result = await client.exec(sandbox.id, { command: 'sleep 60', abortSignal: controller.signal, }); ``` ### File operations ```ts // Write a file await client.writeFile(sandbox.id, '/home/user/hello.txt', 'Hello, world!'); // Write without overwriting await client.writeFile(sandbox.id, '/home/user/hello.txt', 'new content', false); // Read a file const buf = await client.readFile(sandbox.id, '/home/user/hello.txt'); console.log(buf.toString()); // Append to a file await client.appendFile(sandbox.id, '/home/user/log.txt', 'new line\n'); // Delete a file await client.deleteFile(sandbox.id, '/home/user/hello.txt'); // Delete a directory recursively await client.deleteFile(sandbox.id, '/home/user/node_modules', { recursive: true }); // Create a directory await client.mkdir(sandbox.id, '/home/user/src/components', true); // List files const files = await client.listFiles(sandbox.id, { path: '/home/user/src', recursive: true, extension: '.ts', }); // Get file metadata const stat = await client.stat(sandbox.id, '/home/user/hello.txt'); console.log(stat.size, stat.type); // 13 "file" // Copy a file await client.copyFile(sandbox.id, { src: '/home/user/hello.txt', dest: '/tmp/hello-copy.txt', }); // Move / rename a file await client.moveFile(sandbox.id, { src: '/tmp/hello-copy.txt', dest: '/tmp/renamed.txt', }); ``` ### Error handling All API errors throw `SandboxServiceError`: ```ts import { SandboxServiceError } from '@n8n/sandbox-client'; try { await client.getSandbox('nonexistent-id'); } catch (err) { if (err instanceof SandboxServiceError) { console.log(err.status); // 404 console.log(err.message); // "sandbox not found" } } ``` `deleteSandbox` treats HTTP 404 as success (already gone), so a retried delete after a dropped `204` does not fail. The client never follows redirects: a 3xx is thrown as `SandboxServiceError` like any other non-2xx, so the API key is never re-sent to a different host. Point `baseUrl` at the service directly, not at something that redirects to it. ### Sandbox restarts If a sandbox's guest crashes, the service recovers it by rebooting its filesystem and then fails the request that triggered the recovery with `SandboxCrashedError` (HTTP 409). The files are intact; everything that was in memory is not. Retry the request once — the sandbox is already running again — and relaunch whatever it was running in the background: ```ts import { SandboxCrashedError } from '@n8n/sandbox-client'; try { return await client.exec(sandboxId, { command: 'curl -s localhost:3000' }); } catch (err) { if (err instanceof SandboxCrashedError) { // Files survived; the dev server this depends on did not. await client.exec(sandboxId, { command: 'npm run dev &' }); return await client.exec(sandboxId, { command: 'curl -s localhost:3000' }); } throw err; } ``` It is never retried automatically: an invisible retry would hide the loss. After a restart, a completed execution is no longer readable (`resumeExecution` throws a 404 `SandboxServiceError`). An idle stop can also lose memory (on the Docker runtime a stopped container is restarted, not resumed) but raises no `SandboxCrashedError`; it shows as `status: "stopped"` in `getSandbox`, whereas a crash leaves `status` at `running`. Details: [API.md](https://github.com/n8n-io/n8n-sandbox-service/blob/main/docs/API.md#http-409-sandbox_restarted--the-sandbox-came-back-without-its-memory). ## Breaking change in 1.0 `createSandbox(options)` now requires `options.egress`. Pass `egress: 'public'` to keep the 0.x behaviour. `SandboxRecord` gains `egress`. Reconnecting by `id` with a different `egress` fails (`409`). ## Development ```sh pnpm install pnpm build # Build CJS + ESM + types pnpm typecheck # Type-check without emitting pnpm test # Run tests once pnpm test:dev # Run tests in watch mode ```