@n8n/sandbox-client
v1.1.0
Published
Client for the n8n sandbox service API
Keywords
Readme
@n8n/sandbox-client
Client for the n8n sandbox service API.
Install
pnpm add @n8n/sandbox-clientUsage
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). 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. - For other statuses, this policy retries a
POSTonly where the service makes it idempotent:createSandboxwith an explicitid.exechas its own loop (below).
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.
// 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
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); // 42Stream output as it arrives:
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:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const result = await client.exec(sandbox.id, {
command: 'sleep 60',
abortSignal: controller.signal,
});File operations
// 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:
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:
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.
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
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