disk
v1.9.0
Published
Pure-JS client and CLIs for Archil disks and sandboxes
Readme
disk
SDK and CLI for Archil disks. Create disks, list and inspect them, manage who can mount them, and run commands against them — all from scripts, CI, or an interactive terminal. It also ships drop-in filesystem tools for AI SDK, Mastra, and Langchain.
disk talks to the Archil control plane over HTTPS and has no native dependencies. If you also want to mount a disk's data plane from Node (rare — most users want disk exec or the archil CLI), install @archildata/native alongside disk.
Install
npm install diskCLI
# Authenticate
export ARCHIL_API_KEY=key-...
export ARCHIL_REGION=aws-us-east-1
# Create a disk — the response includes a one-time disk token you'll need to mount it
npx disk create my-disk
# List and inspect
npx disk list
npx disk get dsk-abc123
# Run a command against the disk's contents — Archil spins up a container with the disk
# mounted, runs the command, and returns stdout/stderr/exit code.
npx disk dsk-abc123 exec "ls -la /mnt"
# Delete
npx disk delete dsk-abc123
# Manage account-level API keys
npx disk api-keys list
npx disk api-keys create ci-bot
npx disk api-keys delete key-abc123list and get pretty-print tables by default; pass -o json to pipe into jq. Credentials come from ARCHIL_API_KEY / ARCHIL_REGION, or --api-key / --region / --base-url flags.
Profiles
disk and sandbox share named profiles:
npx disk profile create --profile test-yellow --region aws-us-east-1
npx sandbox profile use test-yellow
npx sandbox listprofile create securely prompts for the API key. Flags override environment variables, which override the selected profile. The library API does not read profiles.
Sandbox CLI
The sandbox executable manages persistent sandboxes:
npx sandbox list
npx sandbox create dev --vcpu-count 4 --mem-size-mib 8192 --env NODE_ENV=development
npx sandbox pause dev
npx sandbox resume dev --no-wait
npx sandbox wait dev --status running --timeout 60
npx sandbox fork dev agent-task
npx sandbox run dev -- sh -c 'echo "$NODE_ENV"'
npx sandbox shell dev
npx sandbox stop dev
npx sandbox delete devCommands accept an exact ID or unique name. Lifecycle commands wait by default; use --no-wait to return once accepted. Use -o json for structured output. Starting a paused sandbox discards its memory snapshot and prompts unless --yes is passed. In a shell, Ctrl+] is the emergency escape.
Library
Setup:
import * as archil from "disk";
// Configure once per process — falls back to ARCHIL_API_KEY / ARCHIL_REGION env vars.
archil.configure({ apiKey: process.env.ARCHIL_API_KEY, region: "aws-us-east-1" });
// Create a disk. `token` here is the disk token — the one-time credential for mounting.
const { disk, token } = await archil.createDisk({ name: "my-disk" });
console.log(`Created ${disk.id}, disk token: ${token}`);
// List and look up disks
const all = await archil.listDisks();
const d = await archil.getDisk(disk.id);Per-disk operations are methods on the Disk object itself, not top-level functions:
const d = await archil.getDisk("dsk-abc123");
// Run a command in a container with the disk mounted
const { stdout, stderr, exitCode } = await d.exec("ls -la /mnt && cat /mnt/config.json");
// Manage who can mount the disk
const user = await d.addUser({ type: "token", nickname: "ci" });
await d.removeUser("token", user.identifier!);
// Delete
await d.delete();Private certificate authorities (Node.js)
For BYOC endpoints signed by a private CA, configure trust on the client:
import { readFileSync } from "node:fs";
import { getCACertificates } from "node:tls";
import { Archil } from "disk";
const client = new Archil({
apiKey: process.env.ARCHIL_API_KEY,
region: "aws-us-east-2",
baseUrl: "https://control.archil.example.internal",
s3BaseUrl: "https://s3.archil.example.internal",
tls: {
ca: [...getCACertificates(), readFileSync("archil-ca.pem")],
},
});tls.ca accepts a PEM string, a Buffer, or an array of either. As with Node's
TLS ca option, it replaces the default trust list. The example includes Node's
defaults as well as the private CA; getCACertificates() requires Node 22.15+.
Omitting tls.ca preserves Node's default trust behavior.
The configuration applies to this client's control-plane and S3-compatible requests, with certificate and hostname verification enabled. It does not change Node's global dispatcher or default trust. HTTP/2 pooling is preserved, and clients only share pools when their endpoint, API key, and CA configuration match. CA arrays and buffers are copied at construction; create a new client to change trust settings. Custom CAs are not supported in browsers.
Sandboxes
Use Archil.sandboxes to manage persistent VMs:
const client = new archil.Archil({
apiKey: process.env.ARCHIL_API_KEY,
region: "aws-us-east-1",
});
const sandbox = await client.sandboxes.create({
name: "prepared-environment",
vcpuCount: 4,
memSizeMiB: 8192,
});
const result = await sandbox.exec("uname -a");
console.log(result.stdout);
const terminal = await sandbox.run("codex", {
terminal: { cols: 120, rows: 40 },
onOutput: ({ data }) => process.stdout.write(data),
});
const processId = terminal.id;
await terminal.sendInput("Review this repository\n");
await terminal.resize({ cols: 160, rows: 50 });
const cursor = terminal.cursor;
await terminal.disconnect();
const resumed = await sandbox.attach(processId, {
offset: cursor,
onOutput: ({ data }) => process.stdout.write(data),
});
await resumed.kill();
await sandbox.start();
await sandbox.resume();
await sandbox.stop();
const fork = await sandbox.fork({ name: "agent-task" });
await fork.stop();
await fork.delete();
const all = await client.sandboxes.list();
const usingDisk = await client.sandboxes.list({ disk: "dsk-abc123" });Build a sandbox image from an OCI reference, then create sandboxes from it:
const image = await client.images.build({
source: "ghcr.io/acme/agent:v3",
// Private images only.
registryAuth: { username: "bot", password: process.env.GHCR_TOKEN! },
});
const agent = await client.sandboxes.create({ imageId: image.imageId });
console.log(agent.imageDigest);build() waits until the image is ready, joining a build already in progress.
It times out after 30 minutes by default ({ timeoutSeconds } changes this)
with an ArchilError whose code is IMAGE_BUILD_TIMEOUT. Building a tag
again rebuilds it in case the tag moved, so build once and reuse imageId; a
built digest reference (repo@sha256:...) returns immediately. Archil never
stores registry credentials: pass registryAuth on every build of a private
image, since without it the same source names the public image. Interrupted
builds are requested again up to three times. A failed build throws
ImageBuildError; its reason is invalid_source when the registry reported
the image missing or denied access, or interrupted. images.get(imageId)
returns the image's current state. imageId cannot be combined with
baseImage, and creating a sandbox from an image without a successful build
fails with a 409 ArchilApiError whose code is image_not_ready.
Expose TCP ports publicly when creating a sandbox or later with exposePort:
const web = await client.sandboxes.create({ baseImage: "python:3.12-slim", ports: [8080] });
const server = await web.run("python -m http.server 8080 --bind 0.0.0.0");
await server.disconnect(); // The server keeps running.
const hostname = await web.exposePort(8080); // Returns the hostname, including if already public.
console.log(`https://${hostname}`); // Available once the server is listening.
console.log(await web.listPorts()); // [{ port: 8080, hostname: "..." }]
await web.unexposePort(8080);Ports must be between 1 and 65535. Exposing a port makes it publicly accessible
without authentication; your application must listen on that port. HTTP services
are reached through HTTPS on the returned hostname.
listPorts() returns only explicitly exposed ports; sandbox.endpoints contains
service-published ports. Deleting explicit exposure leaves a service publishing
the same port reachable and does not stop the listening process. Routing changes
may take a few seconds to propagate, and existing connections remain open.
fork pauses a running sandbox while the snapshot is taken and resumes it once the
fork is accepted. A paused or stopped sandbox is forked in place and left as it is.
For private HTTP access, create a port token without exposing the port publicly:
const privateWeb = await client.sandboxes.create({ baseImage: "python:3.12-slim" });
const server = await privateWeb.run("python -m http.server 8080 --bind 0.0.0.0");
await server.disconnect();
const access = await privateWeb.createPortToken(8080, { ttl: "1h" });
// Once the server is listening:
const response = await fetch(`https://${access.hostname}/`, {
headers: { "X-Archil-Token": access.token },
});
console.log(await response.text());
const metadata = await privateWeb.getPortToken(access.id);
const tokens = await privateWeb.listPortTokens();
const page = await privateWeb.listPortTokensPage({ limit: 10 });
await privateWeb.deletePortToken(access);
await privateWeb.delete();The secret and hostname are returned only by createPortToken; get/list return
metadata. Save the secret when creating it. Omit ttl for no expiration, or use
a duration up to "8760h" (365 days). listPortTokens follows all pages (its
limit caps the total); listPortTokensPage returns nextCursor for pagination.
Public ports bypass token authentication. Revocation and expiry deny new
connections with HTTP 401; existing connections remain open.
Archil disks can be mounted inside the sandbox at creation. They are mounted on every boot and persist across stop/start and pause/resume. A sandbox with mounts must be stopped before it is forked; the fork inherits them:
const workspace = await client.sandboxes.create({
mounts: [
{ disk: "dsk-0123456789abcdef", path: "/mnt/archil" },
{ disk: "dsk-fedcba9876543210", path: "/mnt/models", subdirectory: "llama", readOnly: true },
{ disk: "dsk-0000000000000001", path: "/workspace", conditional: true },
],
});
console.log(workspace.mounts.map((mount) => mount.path));
await workspace.stop();
await workspace.start({ mounts: [{ disk: "dsk-0123456789abcdef", path: "/mnt/other" }] });These disks are additional mounts; the sandbox retains its internal root disk.
A sole mount defaults to /mnt/archil; path is required for every mount when
more than one disk is mounted. Options: readOnly, conditional
(concurrent writers without delegation checkouts), subdirectory, and queueMs (how long a
plain mount waits for the disk's exclusive root delegation). A plain read-write mount holds
that delegation for as long as the sandbox does, including while paused, so use readOnly
or conditional for disks that other clients or forks also write.
On a cold start, omit mounts to retain the current list, pass mounts: [] to
clear it, or supply a list to replace it. Mounts cannot be changed on resume or
while the sandbox is running. queueMs cannot be combined with readOnly or conditional.
Network egress can optionally be restricted when creating a sandbox:
const restricted = await client.sandboxes.create({
network: {
egress: {
default: "deny",
allow: [
"github.com",
"*.github.com",
"140.82.112.0/20",
{
target: "api.openai.com",
transform: {
headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
},
},
],
},
},
});An egress policy accepts IPv4 addresses, CIDR ranges, exact domains, and *.
wildcard domains. The default action applies when no target matches. allow
and deny can both be specified; deny matches take precedence. Domain matching
applies to plaintext HTTP and HTTPS traffic, and a wildcard such as
*.github.com matches subdomains, not github.com itself. An object-form allow
rule can transform outbound HTTPS requests to its exact lowercase domain.
Header transformations overwrite values supplied by the sandbox; matching
plaintext HTTP requests are rejected. Omit network for unrestricted egress. A
running sandbox's complete policy can be replaced without restarting it;
existing connections are not terminated:
const effective = await restricted.updateNetwork({
egress: { default: "deny", allow: ["api.github.com"] },
});
console.log(effective, await restricted.getNetwork());
await restricted.updateNetwork({}); // Restore unrestricted egress.Set hard and idle TTLs at creation or edit them while running. All values are seconds, matching the Python SDK:
const timed = await archil.sandboxes.create({ maxTtlSeconds: 3600, idleTtlSeconds: 300 });
await timed.setTimeout(7200);
await timed.setTimeout({ idleTtlSeconds: 60 });
await timed.setTimeout({ timeoutSeconds: 3600, idleTtlSeconds: 300 });
await timed.setTimeout({ idleTtlSeconds: 0 });
console.log(timed.maxTtlSeconds, timed.idleTtlSeconds);Omitted settings stay unchanged on edits. Updating the hard TTL resets its deadline from now; an idle-only edit does not. Changes made while inactive apply to the next powered-on session. Idle TTL accepts 0–86,400 seconds; omitted or zero at creation disables it, and zero on edits disables it again.
Both TTLs pause the sandbox, preserving memory and processes. Open direct process connections prevent idle expiry, but not hard expiry. The idle countdown starts when the last connection closes; detached processes and service-port traffic do not keep it alive. Disable idle TTL for unattended jobs.
Sandboxes support 1–32 vCPUs and 256–65,536 MiB of memory. When omitted,
vcpuCount defaults to 1 and memSizeMiB defaults to 2,048 MiB. Sandbox
timeouts default to 24 hours and can be reset up to 24 hours from now.
Pass an absolute cwd to sandbox.run() or sandbox.exec() to choose the
working directory, for example sandbox.exec("pytest", { cwd: "/workspace/app" }).
Omitting it keeps the sandbox's default working directory.
sandbox.run() always returns a runtime-owned process immediately.
Pass terminal: true when the command needs terminal behavior, or provide
{ cols, rows } for an initial size. Terminal processes merge stdout and stderr
into stdout; non-terminal processes keep the streams separate. Disconnecting
leaves the process running. Reconnect with sandbox.attach(id) to
replay buffered output from the beginning, or pass { offset: process.cursor }
to continue where the previous connection stopped. onOutput receives raw
bytes with their stream and absolute offset. Call closeStdin() to deliver EOF
to a non-terminal process. sendInput() streams large input as 1 MiB WebSocket
frames. Set collectOutput: false to stream through onOutput without
retaining decoded output in the process handle or result. Resize and kill use
separate one-shot process controls, so they do not wait behind stdin.
kill() returns after the control is acknowledged; wait() observes exit.
maxConcurrentExecs limits attached process sessions; detached processes and
one-shot controls do not count. Pausing a sandbox disconnects attachments but
preserves its processes for reattachment after resume. Processes end when
their sandbox is stopped or expires.
sandbox.exec() is the one-call start-and-wait convenience for ordinary
commands. It uses sandbox.run() internally and does not create a durable
control-plane exec record; use sandbox.run() when you need the
process handle. Replace sandbox.processes.start() with sandbox.run() and
sandbox.processes.connect() with sandbox.attach(). The old methods and the
SandboxProcesses export remain available as deprecated compatibility APIs and will
be removed in the next version.
Transfer files through the sandbox process connection without buffering the whole file in memory:
await sandbox.files.uploadFile(file.stream(), "/workspace/input.tar.gz");
const writable = await fileHandle.createWritable();
await sandbox.files.downloadFile("/workspace/result.json", (chunk) =>
writable.write(chunk),
);
await writable.close();Downloads request one bounded chunk at a time. A short or empty chunk marks end-of-file, so the source does not need to be sized before transfer.
uploadFile() accepts a Uint8Array, ArrayBuffer, Blob, async iterable, or
ReadableStream. downloadFile() passes each chunk to the provided writer and
waits for it before requesting more data. Uploads replace the remote target only
after the transfer succeeds.
API keys live at the account level, so those helpers are top-level:
await archil.listApiKeys();
await archil.createApiKey({ name: "ci-bot", description: "GitHub Actions" });
await archil.deleteApiKey("key-abc123");Reading and writing objects
A Disk doubles as an S3-compatible bucket: read, write, delete, and list its
files by key without mounting it. These methods talk to Archil's S3 endpoint
using your same API key (no separate S3 credentials or SigV4 signing on your
part).
const d = await archil.getDisk("dsk-abc123");
// Write — accepts a string, Uint8Array/Buffer, or ArrayBuffer. Returns the etag.
const { etag } = await d.putObject("reports/2026-01/data.json", JSON.stringify(report), "application/json");
// Read — returns the bytes (a Uint8Array).
const bytes = await d.getObject("reports/2026-01/data.json");
const text = new TextDecoder().decode(bytes);
// Metadata / existence without downloading the body
const meta = await d.headObject("reports/2026-01/data.json"); // null if absent
if (await d.objectExists("reports/2026-01/data.json")) { /* … */ }
// Delete (idempotent — deleting a missing key succeeds)
await d.deleteObject("reports/2026-01/data.json");POSIX ownership and directories
Pass uid, gid, and mode when the files will be used by a non-root process:
await d.putObject("path/a/b/file.txt", "hello", {
uid: 1000,
gid: 1001,
mode: 0o640,
});If path/ already exists as 2000:2000 0700 and a/ and b/ are missing,
the resulting tree is:
| Path | Owner | Mode | Result |
| --- | --- | --- | --- |
| path/ | 2000:2000 | 0700 | Existing directory; unchanged |
| path/a/ | 1000:1001 | 0755 | Implicit parent created by the upload |
| path/a/b/ | 1000:1001 | 0755 | Implicit parent created by the upload |
| path/a/b/file.txt | 1000:1001 | 0640 | Published file with the requested attributes |
The requested file mode never applies to implicit parents; they use 0755 so
they remain traversable. Existing directories are never re-owned or re-moded,
so in this example a FUSE process running as uid 1000 still cannot traverse the
pre-existing path/ directory.
Create an explicit directory by putting an empty directory-marker key ending in
/:
await d.putObject("path/a/private/", "", {
uid: 4000,
gid: 4001,
mode: 0o750,
});This creates private/ as 4000:4001 0750. If the marker already exists, its
attributes are unchanged. When attributes are omitted, files default to
root:root 0644 and directories to root:root 0755. Automatic multipart
uploads and append-created files use the same directory rules.
The disk root itself defaults to root:root 0755, which means an
unprivileged process cannot create entries directly under the mount root. To
avoid a post-mount chown, set the root's owner and mode when creating the
disk (creation-time only; a later chown/chmod through a mount changes the
live attributes as usual):
const { disk } = await archil.createDisk({
name: "my-disk",
rootAttrs: { uid: 1000, gid: 1000, mode: 0o755 },
});mode is octal (pass 0o750, not 750). On regions that don't support
rootAttrs yet the field is ignored and the disk is created with the
defaults — check the rootAttrs field on the created disk to confirm it
was applied.
rootAttrs only sets the root directory itself — it does not change how
later writes get their attributes:
- Through a mount, normal POSIX rules apply: entries are owned by the
creating process's uid/gid, with mode derived from its umask. A process
running as the
rootAttrsuid therefore owns everything it creates, with no attributes to pass anywhere. - Through
putObjectand friends, omitted attributes still mean the server defaults (root:root 0644files,root:root 0755directories) — the disk'srootAttrsis not used as a fallback. Keep passinguid/gidon object writes when a non-root process will read them.
listObjects auto-paginates by default, returning every matching key. The first
argument is a key prefix; a non-recursive listing (the default) returns the
immediate level as objects plus subdirectory commonPrefixes:
const { objects, commonPrefixes } = await d.listObjects("reports/"); // one level
const all = await d.listObjects("reports/", { recursive: true }); // whole subtree
const first100 = await d.listObjects("reports/", { limit: 100 }); // cap the total
// Stream pages instead of buffering everything (large listings):
for await (const page of d.listObjectsPages("reports/")) {
for (const obj of page.objects) console.log(obj.key, obj.size, obj.lastModified);
}
// Or drive pagination yourself:
const page = await d.listObjects("reports/", { singlePage: true });
if (page.isTruncated) {
const next = await d.listObjects("reports/", { singlePage: true, continuationToken: page.nextContinuationToken });
}Large uploads and bulk delete
putObject handles any size with one call. Small bodies go through a single
request; large ones are uploaded as a multipart upload automatically — split into
parts, uploaded with bounded concurrency, and assembled, aborting the upload if
any part fails so nothing is left half-staged. You don't pick a different method
for big files. For very large objects the part size is grown automatically so the
upload never exceeds S3's 10,000-part limit.
// Small or multi-gigabyte — same call.
await d.putObject("reports/2026-01/data.json", JSON.stringify(report), "application/json");
const { etag } = await d.putObject("backups/2026-01.tar", bigBytes, {
contentType: "application/x-tar",
multipartThreshold: 5 * 1024 * 1024, // switch to multipart above 5 MiB; default = partSize
partSize: 32 * 1024 * 1024, // ≥ 5 MiB; default 16 MiB
concurrency: 8, // parts in flight at once; default 4
});For manual control over the multipart lifecycle (e.g. uploading parts from
separate processes), the raw S3 primitives live in the opt-in d.multipart
namespace — create, uploadPart, complete, abort, listParts,
listUploads. Most code never needs these.
const { uploadId } = await d.multipart.create("big.bin");
const p1 = await d.multipart.uploadPart("big.bin", uploadId, 1, firstChunk);
const p2 = await d.multipart.uploadPart("big.bin", uploadId, 2, secondChunk);
await d.multipart.complete("big.bin", uploadId, [p1, p2]);deleteObjects removes many keys in one round trip (auto-batched at S3's 1000-key
limit). Unlike deleteObject, per-key failures are returned rather than thrown:
const { deleted, errors } = await d.deleteObjects(["a.txt", "logs/b.txt", "c.txt"]);
for (const e of errors) console.warn(`${e.key}: ${e.code} ${e.message}`);appendObject appends bytes to an existing object (creating it if absent) — handy
for log-style writes. Each call may append at most 1 MiB; append in chunks to grow
past that.
await d.appendObject("logs/app.log", "first line\n");
await d.appendObject("logs/app.log", "second line\n"); // concatenatedTransient failures (HTTP 429 and 5xx, plus network errors) are retried
automatically with jittered exponential backoff before surfacing; caller errors
(other 4xx) are not retried. The two non-idempotent operations —
completeMultipartUpload and appendObject — are not auto-retried, since a
retry after a succeeded-but-unacknowledged call would return a spurious
NoSuchUpload (complete) or duplicate the appended bytes (append).
Failures throw ArchilS3Error with status (HTTP status), code (the S3 error
code, e.g. "NoSuchKey"), requestId, and the raw body on raw. getObject
on a missing key throws a 404 — use headObject/objectExists to probe without
catching. All SDK errors extend ArchilError, so catch (e) { if (e instanceof
ArchilError) … } handles control-plane and S3 failures uniformly.
The S3 endpoint is derived from your region automatically. To target a custom
environment, set s3BaseUrl on the Archil constructor (or the
ARCHIL_S3_BASE_URL env var).
Sharing files
share mints a signed, time-limited link to a single file. Anyone with the link
can download that file — no API key, no mounting. The link carries a
cryptographically signed token (disk + key + expiry); when it expires it stops
working.
const d = await archil.getDisk("dsk-abc123");
// Default lifetime is 24 hours.
const { url, expiresIn } = await d.share("reports/2026-01/summary.pdf");
console.log(url); // https://control.…/api/shared/<token>
// Set the lifetime in seconds (any positive integer, up to 604800 = 7 days):
const weekLink = await d.share("reports/2026-01/summary.pdf", { expiresIn: 604800 });Multiple accounts or regions
For multi-tenant scripts, instantiate Archil directly instead of using the module-level configure:
import { Archil } from "disk";
const prod = new Archil({ apiKey: prodKey, region: "aws-us-east-1" });
const staging = new Archil({ apiKey: stagingKey, region: "aws-us-east-1" });
const prodDisks = await prod.disks.list();
const stagingDisks = await staging.disks.list();Filesystem tools
Support for providing agents with a set of tools for using an Archil disk live in their own @archildata/* packages.
| Package | Framework |
| --- | --- |
| @archildata/ai-sdk | AI SDK |
| @archildata/eve | eve |
| @archildata/mastra | Mastra |
| @archildata/langchain | LangChain / LangGraph |
Workspaces
For usage with multiple disks, you can create a workspace, which acts as a virtual disk where each mounted disk appears as a top-level directory:
import { Archil } from "disk";
const archil = new Archil();
const source = await archil.disks.get(process.env.ARCHIL_SOURCE_DISK_ID!);
const output = await archil.disks.get(process.env.ARCHIL_OUTPUT_DISK_ID!);
const workspace = archil.workspace({
source: { disk: source, readOnly: true },
output,
});Workspace paths route to the right disk by their first segment. readOnly mounts
return an error from operations that mutate the disk.
Workspace mounts can also request delegations:
const workspace = archil.workspace({
repo: {
disk: repoDisk,
checkoutPaths: ["src", "tmp/cache"],
queueMs: 5_000,
},
});When queueMs is set without checkoutPaths, the mount root is acquired
during mount setup instead.
Delegations
A delegation grants a client exclusive write access to an inode on a shared disk. List the delegations currently held on a disk and forcibly revoke one — useful for reclaiming write access from a client that crashed or lost connectivity without checking its delegations in:
const disk = await archil.disks.get("dsk-0123456789abcdef");
for (const d of await disk.listDelegations()) {
// { clientId, inodeId, path?, isPending, isOrphaned }
if (d.isOrphaned) {
await disk.revokeDelegation(d);
}
}A delegation has no ID of its own — it is identified by the
(clientId, inodeId) pair. isOrphaned entries are held by clients no longer
connected to the disk. path is resolved best-effort by the server and may be
absent.
Branches
A branch is an independent, writable fork of a disk that starts from a
committed checkpoint. Writes on a branch don't affect its source. Create a
checkpoint from a mounted disk with archil checkpoints create <mountpoint> <name>,
then branch from it:
const disk = await archil.disks.get("dsk-0123456789abcdef");
const branch = await disk.createBranch({ name: "experiment", fromCheckpoint: "nightly" });
// { branch_name, filesystem_id, from_checkpoint_name, created_at, ... }
// Branch from a checkpoint taken on another branch:
await disk.createBranch({ name: "experiment-2", fromCheckpoint: "cp1", fromBranch: "experiment" });
await disk.listBranches();
await disk.getBranch("experiment");Branch names are 1-100 letters, digits, hyphens, or underscores, and are unique
across the disk. createBranch rejects with a 409 ArchilApiError when the
name is taken or the checkpoint isn't committed yet. The SDK's own retries are
safe: it sends the same idempotency key on every attempt. To retry a create
yourself, pass your own key and reuse it, so a retry of a create that already
succeeded returns that branch instead of a 409:
const idempotencyKey = crypto.randomUUID(); // store it alongside the job
await disk.createBranch({ name: "experiment", fromCheckpoint: "nightly", idempotencyKey });A Workspace is a full filesystem in its own right — it has the same object API
a Disk does (getObject / putObject / deleteObject / listObjects /
grep / exec; both implement the FileSystem interface), so you can use it
directly, and add or remove disks at runtime. A
workspace's keys carry the disk name as their first segment:
const data = await ws.getObject("data/reports/q1.csv"); // routes to the "data" disk
ws.addDisk("scratch", diskTmp); // mount another disk live; ws.removeDisk("scratch")Connecting to a disk's data plane
To run a command against a disk, use Disk.exec() — it returns stdout, stderr, and an exit code from an Archil-managed container with the disk pre-mounted. No local filesystem involved.
To mount a disk as a real filesystem on your machine, use the archil CLI — it mounts through the OS kernel via FUSE, so any program can read and write files with standard APIs.
For the rare case where you need raw Archil protocol access from Node.js (inodes, delegations, byte-level reads), install @archildata/native alongside disk:
npm install disk @archildata/nativeThen Disk.mount() lazy-loads the native client:
import { getDisk } from "disk";
const d = await getDisk("dsk-abc123");
const client = await d.mount({ authToken: "<disk-token>" });
// `client` is an ArchilClient from @archildata/native — see that package's README.
await client.close();@archildata/native supports Linux (x64 / arm64, glibc) and macOS (arm64). On other platforms, mount() throws; the rest of disk still works.
Supported regions
| Region | Provider |
| ----------------- | -------- |
| aws-us-east-1 | AWS |
| aws-us-west-2 | AWS |
| aws-eu-west-1 | AWS |
| gcp-us-central1 | GCP |
FAQ
What's the difference between an API key and a disk token?
Archil has two credential types, and the examples above use both:
- API key — account-level credential for the control plane. You use one whenever you call
disk(CLI or library). Create and manage them at console.archil.com or withdisk api-keys create. Goes in theARCHIL_API_KEYenv var or the--api-keyflag. - Disk token — per-disk credential that lets a client mount a specific disk. Created automatically when you
disk create <name>(the value is shown once; save it). You don't need one to rundiskitself — only when something is actually mounting a disk.
Support
Questions, feature requests, or issues? Reach us at [email protected].
To finish selected model requests before pausing, opt in when creating the sandbox:
const sandbox = await archil.sandboxes.create({
network: {
egress: {
default: "allow",
drain_on_pause: [
"*.anthropic.com",
"api.openai.com",
"bedrock-runtime.*.amazonaws.com",
],
},
},
});
await sandbox.pause();Each entry is a lowercase hostname pattern without a scheme, port, or path.
* works anywhere; "*" selects every host. Leading *. excludes the apex domain.
All URL paths on a selected host count. Matching happens before forwarding. Selectors
do not grant network access. Pausing gates new matches and waits for active HTTP(S)
responses, including streams, to reach the guest. Requests to other hosts do not delay the drain.
The drain waits up to ten minutes by default, starting when pause begins. Request errors
stop counting; expiry proceeds with the snapshot. Requests have no added timeout during
normal operation.
HTTP ports 80 and 443 are supported. Clients must trust the sandbox egress CA for HTTPS
and retry stale connections after resume. Set selectors at creation to cover every
connection; live network updates cannot intercept existing passthrough connections.
A snapshot failure leaves the sandbox in failed with the failure reason and raises
SandboxPauseError when waiting; error.latest contains the resulting sandbox state.
