Docs menu

SDK reference

JavaScript SDK

@layerfs/sdk has 29 calls in four groups. Every call takes positional arguments and returns a Promise; none takes an options object except where a signature below shows one.

For coding agents: the layerfs-js-sdk skill is the guide an agent reads to work through the SDK: SKILL.md and one reference file per family, linked under each heading below. Where the skill and this page differ, this page follows the SDK source on main.

How an agent uses it

Agent skillSKILL.md · How an agent works

Every tool call an agent makes is one step, and every step that changes files becomes a Commit on the agent's Branch. Two ways in: fs for cheap, targeted file work, and a Sandbox for Bash commands.

Agent toolLayerFS callsRecorded as
Read, list, search, difffs.read, fs.list, fs.grep, fs.glob, fs.diff_filesNothing: reads don't change history
Write, edit, many-file changefs.write, fs.edit, fs.apply, fs.mkdir, fs.mv, fs.cp, fs.rmOne Commit per call; the whole change lands or nothing does
Bashsandbox.mount → exec → commit → unmountOne Commit with everything the command changed: sources, lockfiles, node_modules, build output
  • Prefer fs for one small read or edit. It runs in your own process, against the Store, with no container and no mount.
  • Use a Sandbox for commands: installs, builds, tests, generators, and changes across many files where one command beats many calls.
  • Commit after every Bash call, even a failing one, with a message naming the step and its result, such as "step 12: npm test (exit 1)". A step that changes nothing answers UpToDate and makes no Commit.

The client

Agent skillSKILL.md · Import the SDKSKILL.md · Connect to a project

The SDK is the sdk/js folder of the LayerFS checkout. It isn't published to npm; link it into your folder once. It needs Node 20 or newer.

npm init -y >/dev/null && npm pkg set type=module
npm install --no-audit --no-fund "file:$LAYERFS_REPO/sdk/js"   # the LayerFS checkout
import { LayerFS, isRefusal } from "@layerfs/sdk";

const layerfs = new LayerFS("shop");   // a project name; no I/O happens yet
// … Store calls and Workspace calls …
await layerfs.close();                 // releases this process's Store

new LayerFS(project) checks the name (1 to 63 of a-z 0-9 _ - ., starting and ending with a letter or digit) and does nothing else. The first call opens what it needs.

Two routes

  • Store calls (project, branch, fs) open the project's Store file in your process and read, save and publish there. They need storePath. They run only on Linux, with the Store on a local ext4, xfs or btrfs filesystem shared with the daemons; anywhere else they fail Unsupported, with no fallback.
  • Workspace calls (sandbox.*) go to the daemon in a Sandbox container, through Docker.
The SDK's two routesIn your process, fs, branch and project calls open the Store file directly and read, save and publish there; this works on Linux with the Store on a local ext4, xfs or btrfs filesystem, and fails Unsupported anywhere else. sandbox calls go through Docker to the LayerFS daemon in a Sandbox, which mounts Workspaces and opens the same Store directly.your process@layerfs/sdknew LayerFS("shop", { storePath })fs · branch · projectStore callsopened in this processLinux · local ext4/xfs/btrfssandbox.*via DockerSANDBOXLayerFS daemon/workspaces/1, /2 …mount · exec · commit · unmountthe daemon opensthe same StoreStoreone SQLite fileshared volume!macOS, a network filesystem, another machine: Store calls failUnsupported. There is no fallback route.
Store calls run in your process; sandbox calls go through Docker to a daemon that opens the same Store.

Environment

Set these for every process of one run, with the same values.

VariableMeaning
LAYERFS_HOMEPrivate folder (mode 0700) where LayerFS records projects and Sandboxes
LAYERFS_STORE_PATHThe installed Store file that Store calls open in this process
LAYERFS_DOCKERDocker Engine socket; default /var/run/docker.sock
LAYERFS_IMAGERuntime image digest, sha256:…
LAYERFS_VOLUMEExisting Docker volume shared by the project's daemons
LAYERFS_DAEMONHost path to the built Linux daemon
LAYERFS_TRACEOptional: a file that gets one JSON line per call

Options

new LayerFS("shop", {
  storePath: "/stores/shop/store.sqlite",   // else LAYERFS_STORE_PATH
  sandbox: "agent-3",                       // an existing Sandbox this agent uses
  storePolicy: { smallFileThreshold: 131072, wholeFileDepth: 16, chunkDepth: 8, metadataDepth: 8 },
  trace: "/runs/7/trace.jsonl",             // one JSON line per call
  traceStep: "step 12",
});

sandbox pins a client to one existing Sandbox, which is how a coordinator hands each agent its own: create the Sandboxes one at a time, then give each agent its id. A selected Sandbox that is closed or missing is refused, never replaced. storePolicy only matters at project.init. layerfs.close()releases the Store; the last client in a process closes it.

sdk.project

Agent skillmethods/project.md

project.init

init(sourcePath)

Returns{ kind: "Initialized", project, layer, root, store }; layer is main's first Layer

RefusesExists (already initialized: use it), InvalidInput, Unknown / Failed (stop and report)

Copies a folder into a new Store as main @ L0, once. Everything is imported, including.git and ignored files. Afterwards the files live in LayerFS; later processes with the sameLAYERFS_HOME find the project without it.

const created = await layerfs.project.init("/work/shop");   // once per project
// { kind: "Initialized", project: "shop", layer: "324afe…", root: "e34e2d…", store: "…" }
ExampleInit once, then reuse
// 1. Init reads the source tree once, ignored files included, and makes it main's first Layer.
await layerfs.project.init("/work/shop")
{ kind: "Initialized", project: "shop", layer: "324a…", root: "e34e…", store: "…" }
// 2. A second Init of the same name is refused. The project already exists: use it.
await layerfs.project.init("/work/shop")
LayerFSError: Exists
// 3. Later clients, in any process, find the project without reading the source again.
const later = new LayerFS("shop")
await later.project.list_branches()
{ items: [{ name: "main", id: null, head: null, … }], hasMore: false }

project.list_branches

list_branches(pageSize = 50, pagePos = 0)

Returnsa page of { name, id, base, head, forkedFrom }, main first

RefusesNoSuchProject, InvalidInput

For main, id and head are null and base is its current Layer.

const page = await layerfs.project.list_branches();
const exists = page.items.some((b) => b.name === "cart");
// items: { name, id, base, head, forkedFrom }; main has id and head null
ExampleCheck for a Branch before forking
// 1. Branches come in pages, main first.
const page = await layerfs.project.list_branches(50, 0)
{ items: [{ name: "main", … }, { name: "cart", base: "324a…", head: "c3c2…", forkedFrom: … }], hasMore: false }
// 2. Fork only if the name is free; forking a taken name is refused.
if (!page.items.some((b) => b.name === "search")) {
await layerfs.branch.fork("search")
}
{ kind: "Forked", branch: "search", head: null }

sdk.branch

Agent skillmethods/branch.md

branch.fork

fork(name, fromRef = "main")

Returns{ kind: "Forked", branch, base, head }; head is null when forked from a main Layer

RefusesNameTaken, ReservedName (main), NoSuchBranch / NoSuchCommit

Creates a Branch from main, a Branch, a Commit or a Layer. Metadata only: nothing is copied.

await layerfs.branch.fork("cart");
// { kind: "Forked", branch: "cart", base: "3279da…", head: null }
ExampleFork from main
// 1. A fork from main starts at its newest Layer and has no Commit yet.
await layerfs.branch.fork("cart")
{ kind: "Forked", branch: "cart", base: "324a…", head: null }
// 2. Its first write makes its first Commit.
await layerfs.fs.write("cart", "/NOTES.md", "Cart rewrite\n", "Add notes")
{ kind: "Committed", head: "c1a0…" }
ExampleFork a fork, or any Commit
// 1. Fork from another Branch's head. Nothing is copied, at any depth.
await layerfs.branch.fork("cart.1", "cart")
{ kind: "Forked", branch: "cart.1", base: "324a…", head: "c3c2…" }
// 2. Or from an older Commit, to try again from an earlier step.
await layerfs.branch.fork("cart.retry", "c1a0…")
{ kind: "Forked", branch: "cart.retry", head: "c1a0…" }
ExampleA name that is taken
// 1. Branch names are unique, and main is reserved.
await layerfs.branch.fork("cart")
await layerfs.branch.fork("main")
LayerFSError: NameTaken LayerFSError: ReservedName

branch.list_commits

list_commits(branch, pageSize = 50, pagePos = 0)

Returnsa page of Commit records: id, branch, parent, secondParent, base, root, message, mergedFrom, time

RefusesNoSuchBranch

Newest first, along first parents. After an uncertain outcome, this is how you check whether a Commit happened.

const log = await layerfs.branch.list_commits("cart", 10, 0);
console.log(log.items.map((c) => `${c.id.slice(0, 12)} ${c.message}`).join("\n"));
ExamplePage through history
// 1. Commits come newest first, along first parents.
const p1 = await layerfs.branch.list_commits("cart", 2, 0)
{ items: [{ id: "c3c2…", message: "Add the route" }, { id: "c2b1…", message: "Tax 8%" }], hasMore: true }
// 2. Ask for the next page at pagePos + items.length.
await layerfs.branch.list_commits("cart", 2, 2)
{ items: [{ id: "c1a0…", message: "Add notes" }], hasMore: false }

branch.diff_commits

diff_commits(from, to, pageSize = 50, pagePos = 0, { cursor, hunks } = {})

Returns{ from, to, items, hasMore, nextCursor }; each item { path, kind, type, before, after, content, metadata, identity, hunks, hunksOmitted }

RefusesInvalidInput (not two Commits), NoSuchCommit

What changed between two Commits. kind is Added, Removed orModified. Pass the previous page's nextCursor as cursor to resume after its last path; hunks: false lists paths without line changes.

const [newest, previous] = (await layerfs.branch.list_commits("cart", 2, 0)).items;
const d = await layerfs.branch.diff_commits(previous.id, newest.id);
console.log(d.items.map((c) => `${c.kind} ${c.path}`).join("\n"));

// Page by path instead of re-walking: resume after the last path listed.
const next = await layerfs.branch.diff_branches("main", "cart", 200, 0, { cursor: d.nextCursor, hunks: false });
ExampleWhat did the last step change?
// 1. Take the two newest Commits.
const log = await layerfs.branch.list_commits("cart", 2, 0)
const [newest, previous] = log.items
c3c2… "Add the route" · c2b1… "Tax 8%"
// 2. Diff them: each changed path with its kind and line hunks.
await layerfs.branch.diff_commits(previous.id, newest.id)
{ items: [ { path: "/src/routes/cart.js", kind: "Added", … }, { path: "/src/basket.js", kind: "Removed", … } ], hasMore: false }

branch.diff_branches

diff_branches(from, to, pageSize = 50, pagePos = 0, { cursor, hunks } = {})

Returnsthe same shape as diff_commits

RefusesInvalidInput (a Commit id), NoSuchBranch

The same, between the current heads of two Branches or main.

ExampleList a big change by path
// 1. hunks: false lists paths without their contents, so a page fills with paths.
const d = await layerfs.branch.diff_branches("main", "cart", 200, 0,
{ hunks: false })
{ items: [{ path: "/src/cart.js", kind: "Modified", hunks: null }, … 200 ], hasMore: true, nextCursor: "/src/k.js" }
// 2. Pass nextCursor back to resume after the last path, instead of re-walking the first pages.
await layerfs.branch.diff_branches("main", "cart", 200, 0,
{ hunks: false, cursor: d.nextCursor })
{ items: [ … 37 ], hasMore: false }

branch.merge

merge(a, b, name, message?)

Returns{ kind: "Merged", fastForward, branch, head, root, sources, mergeBase, conflicts: [], lww }

RefusesConflict (nothing created; detail has sources, mergeBase, conflicts, lww), NoMergeBase, NameTaken, NoSuchBranch

Merges a and b into a new Branch name; a andb don't change. fastForward means one side already held the other. Paths matchinga's /.lww keep the newer version and are listed in lww. There is no overwrite option.

const m = await layerfs.branch.merge("cart", "main", "cart-release", "Merge main before publish");
console.log(m.branch, m.fastForward);   // "cart-release" false
ExampleA fast-forward
// cart.1 forked from cart and added a Commit; cart hasn't moved since.
// 1. One side already holds the other, so the merge just takes the newer head. A and b are left as they were.
await layerfs.branch.merge("cart", "cart.1", "cart.next", "Keep cart.1")
{ kind: "Merged", fastForward: true, branch: "cart.next", head: "c4d3…" }
ExampleBring main in before publishing
// main gained a Layer since cart forked, and both changed different files.
// 1. A three-way merge against the merge base; a clean result is one Commit with two parents.
await layerfs.branch.merge("cart", "main", "cart-release", "Merge main")
{ kind: "Merged", fastForward: false, branch: "cart-release", head: "c5e4…", conflicts: [], lww: [] }
ExampleA conflict creates nothing
// 1. Both sides changed the same lines of src/cart.js.
await layerfs.branch.merge("cart.a", "cart.b", "cart.ab")
LayerFSError: Conflict detail: { sources, mergeBase: "c3c2…", conflicts: [{ path: "/src/cart.js", … }], lww: [] }
// 2. Make the sides agree on those lines (here cart.b takes cart.a's), then merge again.
await layerfs.fs.edit("cart.b", "/src/cart.js", …)
await layerfs.branch.merge("cart.a", "cart.b", "cart.ab")
{ kind: "Merged", fastForward: false, branch: "cart.ab", … }

branch.publish

publish(branch, commit?)

ReturnsAdded with publishedLayer, basedOn, overwroteCount, overwrote (first 50), overwroteHasMore; or UpToDate, or NoChanges

RefusesNoCommit, HeadMoved (main moved during the call), InvalidInput (a Layer id)

Makes main show the Branch head, or the given Commit, as a new Layer. It overwrites: main Layers published since the Branch's base are skipped and listed. If someone may have published since you forked, merge main in first, test the merged Branch, then publish it.

const p = await layerfs.branch.publish("cart-release");
if (p.kind === "Added" && p.overwroteCount) console.log("skipped", p.overwrote);
ExamplePublish a Branch
// 1. The Branch's head becomes main's next Layer.
await layerfs.branch.publish("cart-release")
{ kind: "Added", publishedLayer: { id: "l2d0…" }, basedOn: "l1c3…", overwroteCount: 0 }
// 2. New work forks from the new Layer.
await layerfs.branch.fork("cart-2")
{ kind: "Forked", branch: "cart-2", base: "l2d0…", head: null }
ExamplePublishing overwrites
// 1. search was published as L1 after cart forked from L0. Publishing cart directly skips L1, and says so.
await layerfs.branch.publish("cart")
{ kind: "Added", overwroteCount: 1, overwrote: [{ id: "l1c3…", … }], overwroteHasMore: false }
// 2. To keep it, merge main into the Branch first, then publish the merge.
await layerfs.branch.merge("cart", "main", "cart-release")
await layerfs.branch.publish("cart-release")
{ kind: "Added", overwroteCount: 0 }
ExamplePublish an earlier Commit
// 1. Name a Commit to publish it instead of the head.
await layerfs.branch.publish("cart", "c2b1…")
{ kind: "Added", source: { branch: "cart", commit: "c2b1…" }, … }

sdk.fs

Agent skillmethods/fs-read.mdmethods/fs-write.md

Reads take a ref (main, a Branch, a Commit id or a Layer id) and see committed state only, never a mounted Workspace's unsaved changes. Paths are absolute in that view. A single-file answer starts with{ kind: "ok", snapshot, root, path }, where snapshot names what was read, such as"cart@9f3c0a1b2c4d".

Lists are pages, { items, hasMore, nextPagePos }: pageSize 1 to 1000 (default 50),pagePos from 0. A short page while hasMore is true only means it hit its byte bound. Deep offsets skip linearly, so narrow the path rather than paging far.

const all = [];
for (let pos = 0; ; ) {
  const page = await layerfs.fs.glob("cart", "src/**/*.js", "/", 1000, pos);
  all.push(...page.items);
  if (!page.hasMore) break;
  pos += page.items.length;
}

Reads

fs.read

read(ref, path, startLine = 1, endLine?)

Returnstext, startLine, endLine, truncated, lineTruncated, nextLine, size

RefusesIsDirectory, Binary (NUL or invalid UTF-8), NoSuchPath

A 1-based, inclusive line range of a text file: at most 2000 lines and 48 KiB per answer. A huge single line comes back as a prefix and the next window starts at the following line.

// Read a whole file, window by window.
let line = 1, text = "";
for (;;) {
  const r = await layerfs.fs.read("cart", "/src/cart.js", line);
  text += r.text;
  if (r.nextLine == null) break;
  line = r.nextLine;
}
ExampleRead a long file in windows
// 1. A read returns up to 2000 lines. A longer file says where the next window starts.
await layerfs.fs.read("cart", "/src/cart.js")
{ text: "…", startLine: 1, endLine: 2000, truncated: true, nextLine: 2001, size: 91204 }
// 2. Ask again from nextLine until it is null.
await layerfs.fs.read("cart", "/src/cart.js", 2001)
{ text: "…", startLine: 2001, endLine: 2400, truncated: false, nextLine: null }
ExampleA few lines, at any ref
// 1. Lines are 1-based and inclusive. The ref can be a Branch, main, a Commit or a Layer.
await layerfs.fs.read("c2b1…", "/src/cart.js", 1, 5)
{ text: "import …\n…", startLine: 1, endLine: 5, truncated: false }
ExampleNot a text file
// 1. A file with NUL bytes or invalid UTF-8 is refused. Use a Sandbox command for binary work.
await layerfs.fs.read("cart", "/public/logo.png")
LayerFSError: Binary

fs.list

list(ref, path = "/", pageSize = 50, pagePos = 0)

Returnsa page of { name, path, type, inode }; type is file, dir or symlink

RefusesNotDirectory, NoSuchPath

One folder, names in byte order.

fs.stat

stat(ref, path)

Returns{ type, inode, size }

RefusesNoSuchPath

fs.grep

grep(ref, pattern, path = "/", pageSize = 50, pagePos = 0)

Returnsa page of { path, line, text, lineTruncated }, plus skipped binary files

RefusesInvalidInput (empty pattern)

Lines containing pattern as plain text, not a regular expression, in path order then line order. It searches everything under path, node_modules included, so keep path narrow. For a regular expression, run grep -rn in a Workspace.

ExampleFind every use of a name
// 1. Search the contents under a folder. Each match gives its path, line and text.
await layerfs.fs.grep("cart", "TAX", "/src")
{ items: [{ path: "/src/cart.js", line: 3, text: "const TAX = 0.07;" }, { path: "/src/checkout.js", line: 12, text: "total * (1 + TAX)" }], hasMore: false, skipped: [] }

fs.glob

glob(ref, pattern, path = "/", pageSize = 50, pagePos = 0)

Returnsa page of absolute path strings

* and ? match within one name; **/ matches zero or more folders, so src/**/*.js includes /src/cart.js.

ExampleCollect every file of a kind
// 1. ** matches any number of folders, including none.
const page = await layerfs.fs.glob("cart", "src/**/*.js", "/", 1000, 0)
{ items: ["/src/cart.js", "/src/routes/cart.js", "/src/server.js"], hasMore: false }
// 2. Results are pages: while hasMore is true, ask again at pagePos + items.length.
if (page.hasMore) {
await layerfs.fs.glob("cart", "src/**/*.js", "/", 1000, page.items.length)
}

fs.diff_files

diff_files(leftRef, leftPath, rightRef, rightPath)

Returnskind (Text, Equal or Binary), left, right, contentEqual, sameInode, hunks of { op, text }, hunksOmitted

Two files, at any refs and paths. op is " ", "-" or "+".

ExampleOne file, two refs
// 1. Compare a file on main with the same file on a Branch.
await layerfs.fs.diff_files("main", "/src/cart.js", "cart", "/src/cart.js")
{ kind: "Text", contentEqual: false, hunks: [{ op: "-", text: "const TAX = 0.07;" }, { op: "+", text: "const TAX = 0.08;" }] }

fs.blame

blame(ref, path, fromLine = 1)

Returnslines of { n, text, lineTruncated, commit, message }, versions, comparisonCoarse, nextLine

The Commit, and so the step, that last changed each line. comparisonCoarse means a large change was reported as one removed and added block.

ExampleWho wrote each line
// 1. Each line names the Commit, and so the tool call, that last changed it.
await layerfs.fs.blame("cart", "/src/cart.js")
{ lines: [ { n: 1, text: "import …", commit: "c1a0…", message: "Add cart" }, { n: 3, text: "const TAX = 0.08;", commit: "c2b1…", message: "Tax 8%" }, … ], versions: 3 }

Writes

Each write changes one Branch (never main) and publishes one Commit, or nothing. It merges with the Branch head it finds when it publishes: if another writer changed different files meanwhile, it lands on top; if it changed the same path, the write is refused as Conflict and nothing is committed. Every write answers one of:

kindFieldsMeaning
Committedbranch, head, commitA new Commit; commit is its record
UpToDatebranch, head, rootNothing changed, so no Commit

Shared refusals: ReadOnly (main), NoSuchBranch, Conflict, HeadMoved,NoSuchPath (a missing parent: fs.mkdir first), TooLarge andInvalidInput. A file's body is streamed, so no file size is capped, but the rest of one request (paths, edit texts, message) must fit 65,519 bytes; past that it's TooLarge before anything is sent. A Workspace mounted earlier doesn't see a write until you mount again or its next Commit merges it in.

fs.write

write(branch, path, content, message?)

ReturnsCommitted or UpToDate

RefusesIsDirectory

Creates or replaces one file. content is a string, or bytes.

ExampleWrite, then write the same again
// 1. A write creates or replaces the whole file, as one Commit.
await layerfs.fs.write("cart", "/src/routes/cart.js", "export default {};\n", "Add route")
{ kind: "Committed", branch: "cart", head: "c4d3…" }
// 2. Writing what is already there changes nothing, so no Commit is made.
await layerfs.fs.write("cart", "/src/routes/cart.js", "export default {};\n")
{ kind: "UpToDate", branch: "cart", head: "c4d3…" }

fs.edit

edit(branch, path, oldText, newText, message?)

ReturnsCommitted or UpToDate

RefusesNoMatch, AmbiguousMatch, Binary

Replaces exactly one occurrence of oldText. It streams large files, so it's the cheap way to change a few lines of a big one. On NoMatch or AmbiguousMatch nothing changed: read the file as it is now and include more surrounding lines.

const r = await layerfs.fs.edit("cart", "/src/cart.js", "const TAX = 0.07;", "const TAX = 0.08;", "cart: tax 8%");
console.log(r.kind, r.head);   // "Committed" "1280…"
ExampleReplace one exact match
// 1. oldText must appear exactly once. The edit is one Commit.
await layerfs.fs.edit("cart", "/src/cart.js",
"const TAX = 0.07;", "const TAX = 0.08;", "Tax 8%")
{ kind: "Committed", branch: "cart", head: "c2b1…" }
ExampleNo match, or more than one
// 1. Text that isn't there is refused.
await layerfs.fs.edit("cart", "/src/cart.js", "const TAX = 0.07;", "…")
LayerFSError: NoMatch
// 2. So is text that appears more than once. Read the file again and widen oldText until it's unique.
await layerfs.fs.edit("cart", "/src/cart.js", "qty", "quantity")
LayerFSError: AmbiguousMatch
ExampleAnother agent got there first
// 1. Agent A edits cart.js and lands.
await a.fs.edit("cart", "/src/cart.js", "price * qty", "cents(price) * qty")
{ kind: "Committed", head: "c7a6…" }
// 2. Agent B changes the same file from an older head. Refused; nothing is committed or retried.
await b.fs.edit("cart", "/src/cart.js", "price * qty", "price * quantity")
LayerFSError: Conflict detail: { conflicts: ["/src/cart.js"], head: "c7a6…" }
// 3. B reads the new head and edits again.
await b.fs.read("cart", "/src/cart.js")
await b.fs.edit("cart", "/src/cart.js",
"cents(price) * qty", "cents(price) * quantity")
{ kind: "Committed", head: "c8b7…" }

fs.apply

apply(branch, changes, message?)

ReturnsCommitted or UpToDate

Refusesthe first failing change's refusal, with detail.index (zero-based)

An ordered list of write, edit, mkdir, rm, cp andmv changes as one Commit. Later changes see earlier ones. The best way to scaffold or make any multi-file change.

await layerfs.fs.apply("cart", [
  { op: "mkdir", path: "/src/routes" },
  { op: "write", path: "/src/routes/cart.js", content: "export default {};\n" },
  { op: "edit",  path: "/src/server.js", oldText: "// routes", newText: "import cart from './routes/cart.js';" },
  { op: "mv",    from: "/src/basket.js", to: "/src/legacy/basket.js" },
], "cart: add the route");   // one Commit, or nothing
ExampleSeveral changes, one Commit
// 1. apply runs the changes in order and publishes once.
await layerfs.fs.apply("cart", [
{ op: "mkdir", path: "/src/routes" },
{ op: "write", path: "/src/routes/cart.js", content: "export default {};\n" },
{ op: "mv", from: "/src/basket.js", to: "/src/legacy/basket.js" },
], "Add the route")
{ kind: "Committed", head: "c3c2…" }
ExampleAll or nothing
// 1. If any change fails, none land. detail.index is the zero-based change that failed.
await layerfs.fs.apply("cart", [
{ op: "write", path: "/a.txt", content: "a" },
{ op: "rm", path: "/missing.txt" },
])
LayerFSError: NoSuchPath detail: { index: 1 }

fs.mkdir

mkdir(branch, path, message?)

ReturnsCommitted, or UpToDate for an existing folder

RefusesNotDirectory

Creates a folder and any missing parents.

fs.rm

rm(branch, path, message?)

ReturnsCommitted

RefusesNoSuchPath

Removes a file or a whole folder tree.

ExampleRemove, and still read the old version
// 1. rm removes a file or a whole folder as one Commit.
await layerfs.fs.rm("cart", "/src/legacy", "Drop legacy code")
{ kind: "Committed", head: "c5e4…" }
// 2. Older Commits still have it.
await layerfs.fs.read("c4d3…", "/src/legacy/basket.js")
{ text: "…", startLine: 1, … }

fs.cp

cp(branch, from, to, message?)

ReturnsCommitted

RefusesExists (to exists), InvalidInput (same path, or into its own subtree)

Copies a file or tree. Keeps modes, symlinks and hard links within the copied set; timestamps are new.

fs.mv

mv(branch, from, to, message?)

ReturnsCommitted

Refusesthe same as fs.cp

Moves or renames a file or tree, keeping its identity and timestamps.

sdk.sandbox

Agent skillmethods/sandbox.md

A Sandbox is a container running one LayerFS daemon, bound to one project and one image. Its id is the name you give it, and any later process with the same LAYERFS_HOME can use it and its mount paths. A Workspace is a complete, writable view of a Branch mounted inside it, where ordinary commands run.

  • Each mount is a new Workspace with a new path, such as /workspaces/3. Pass that path to exec, status, commit and unmount; never build one yourself.
  • The view is fixed at mount. It shows the Branch as it was then. commit merges in what moved; to see it, mount again.
  • Everything in it is committed: sources, .git, node_modules, build output, caches. Keep scratch files in /tmp, outside the Workspace.
  • One process at a time per Sandbox. A second one gets Busy. There's no restart recovery: if the container dies, its Workspaces are gone.

One Bash call as one Workspace:

const path = await layerfs.sandbox.mount(id, "cart");           // a NEW Workspace, e.g. "/workspaces/1"
const run = await layerfs.sandbox.exec(id, path, "timeout 110 npm test 2>&1", "step 12");
await layerfs.sandbox.commit(id, path, `step 12: npm test (exit ${run.exitCode})`);
await layerfs.sandbox.unmount(id, path);                        // path is now invalid

sandbox.create

create(name, project, image, { volumes } = {})

Returnsthe Sandbox id, which is name

RefusesExists, InvalidInput, Busy (another create holds the project lock), Failed, Unknown

Starts a Sandbox and waits until it's ready. project must be this client's project andimage a local digest, sha256: plus 64 hex. volumes mounts existing volumes read-only outside /workspaces. Create Sandboxes one at a time. A Failed create withdetail.removed left nothing behind; Unknown blocks later creates until a person cleans up what the message names.

const id = await layerfs.sandbox.create("builder", layerfs.projectName, process.env.LAYERFS_IMAGE, {
  volumes: [{ volume: "pnpm-store", target: "/pnpm" }],   // optional, mounted read-only
});
// id === "builder"
ExampleCreate a Sandbox for an agent
// 1. A named container with its own daemon, for one project and one image. Extra volumes mount read-only.
await layerfs.sandbox.create("builder", "shop", process.env.LAYERFS_IMAGE, {
volumes: [{ volume: "pnpm-store", target: "/pnpm" }],
})
"builder"
// 2. Names are unique in the LayerFS home. Reuse the Sandbox instead of creating it again.
await layerfs.sandbox.create("builder", "shop", process.env.LAYERFS_IMAGE)
LayerFSError: Exists

sandbox.mount

mount(id, branch)

Returnsthe Workspace path, such as "/workspaces/1"

RefusesNoSuchBranch (main can't be mounted: fork it), NoSuchSandbox, Busy

Mounts a Workspace on the Branch head at this moment. It doesn't scan or copy the tree.

ExampleMount a Branch
// 1. Each mount is a new Workspace with its own path. It binds the whole tree without copying it.
await layerfs.sandbox.mount("builder", "cart")
await layerfs.sandbox.mount("builder", "search")
"/workspaces/1" "/workspaces/2"
// 2. main can't be mounted. Fork it and mount the fork.
await layerfs.sandbox.mount("builder", "main")
LayerFSError: NoSuchBranch

sandbox.exec

exec(id, path, command, message?)

Returns{ kind: "exit:<code>", exitCode, stdout, stderr, truncated, message }

RefusesNotMounted, NoSuchSandbox, Busy, Unknown (exit code not known)

Runs bash -c command at the Workspace root (HOME=/tmp) and waits until everything holding its output has ended. A non-zero exit is not a refusal. Each stream keeps its first 1 MiB. There is no timeout, so wrap long commands: timeout 110 npm test. Use relative paths; the message labels the run and creates no history.

const run = await layerfs.sandbox.exec(id, path, "timeout 110 npm test 2>&1", "cart tests");
// { kind: "exit:0", exitCode: 0, stdout: "…# pass 3…", stderr: "", message: "cart tests", truncated: false }

// Big output: write it to /tmp, outside the Workspace, and read the end.
await layerfs.sandbox.exec(id, path, "timeout 110 npm run build >/tmp/build.log 2>&1; echo $? >/tmp/build.code");
const tail = await layerfs.sandbox.exec(id, path, "cat /tmp/build.code; tail -n 60 /tmp/build.log");
ExampleRun a command
// 1. The command runs with bash -c in the Workspace. Bound it with timeout: LayerFS never stops a command itself.
await layerfs.sandbox.exec(id, path, "timeout 110 npm test 2>&1", "Run tests")
{ kind: "exit:0", exitCode: 0, stdout: "# pass 3 …", stderr: "", truncated: false }
// 2. A run makes no history. Commit afterwards to save what it changed.
await layerfs.sandbox.commit(id, path, "npm test")
{ kind: "UpToDate", head: "c3c2…" }
ExampleBig output
// 1. Send long output to a file in /tmp, outside the Workspace, so it isn't committed.
await layerfs.sandbox.exec(id, path,
"timeout 110 npm run build >/tmp/build.log 2>&1; echo $? >/tmp/build.code")
{ kind: "exit:0", exitCode: 0, stdout: "" }
// 2. Then read just the end of it.
await layerfs.sandbox.exec(id, path,
"cat /tmp/build.code; tail -n 60 /tmp/build.log")
{ exitCode: 0, stdout: "0\n… built in 4.2s" }

sandbox.status

status(id, path)

Returns{ base, baseLayer, branchHead, headMovedSinceMount, activity, overlay, branch }

RefusesNotMounted, NoSuchSandbox, Busy

overlay counts what changed since mount: { inodes, entries, pendingMerge }.

const s = await layerfs.sandbox.status(id, path);
if (s.headMovedSinceMount) console.log("the next commit merges with", s.branchHead);
ExampleHas the Branch moved?
// 1. Another agent committed to cart while this Workspace was mounted.
await layerfs.sandbox.status(id, path)
{ base: "c4d3…", branchHead: "c5e4…", headMovedSinceMount: true, branch: "cart", … }
// Nothing to do yet: the next Commit merges with the new head.

sandbox.commit

commit(id, path, message?, force = false)

ReturnsCommitted with head, commit, displacedHead, merged, lww; or UpToDate with head, root

RefusesConflict, HeadMoved, NotMounted, Busy

Saves everything changed in the Workspace as one Commit; the Workspace stays mounted. If the Branch moved since the mount it merges first (merged is then { from, head }). On Conflictnothing is committed and the conflicted files in the Workspace now hold markers: fix them with commands and commit again. force overwrites the newer head instead of merging and drops the other changes; avoid it.

ExampleCommit what a command changed
// 1. The Commit saves the whole Workspace as it stands, including files from background processes.
await layerfs.sandbox.exec(id, path, "timeout 110 npm install dayjs")
await layerfs.sandbox.commit(id, path, "Add dayjs")
{ kind: "Committed", head: "c4d3…", commit: { message: "Add dayjs", … } }
// 2. With nothing changed, it answers UpToDate and makes no Commit.
await layerfs.sandbox.commit(id, path, "Again")
{ kind: "UpToDate", head: "c4d3…" }
ExampleThe head moved meanwhile
// 1. Another agent committed to cart. This Commit changed other files, so it merges and lands on top.
await layerfs.sandbox.commit(id, path, "Tax 9%")
{ kind: "Committed", head: "c6f5…", merged: { from: "c4d3…", head: "c5e4…" } }
ExampleA conflict in a Workspace
// 1. The same file changed on the new head. Nothing is published; marker blocks are written into the file.
await layerfs.sandbox.commit(id, path, "Rename qty")
LayerFSError: Conflict detail: { head: "c8b7…", conflicts: [{ path: "/src/cart.js", kind: "content", … }] }
// 2. Fix the markers with ordinary commands. A Commit is refused while a complete marker block remains.
await layerfs.sandbox.exec(id, path, "…edit src/cart.js…")
await layerfs.sandbox.commit(id, path, "Rename qty")
{ kind: "Committed", head: "c9c8…" }

sandbox.unmount

unmount(id, path, discard = false)

Returns{ kind: "Unmounted" }

RefusesDirty (detail.changed lists uncommitted paths), Busy, NotMounted

Deletes the Workspace's overlay; history stays. Commit first, or pass discard only for a Workspace you're abandoning.

ExampleUncommitted changes
// 1. Unmounting with changes not yet committed is refused, so nothing is lost by accident.
await layerfs.sandbox.unmount(id, path)
LayerFSError: Dirty detail: { changed: ["/scratch.txt"] }
// 2. Commit them, or discard them on purpose.
await layerfs.sandbox.unmount(id, path, true)
{ kind: "Unmounted" }

sandbox.close

close(id)

Returns{ kind: "Closed" }

RefusesMountsActive (unmount every Workspace first), Busy, NoSuchSandbox

Stops and deletes the container and frees its name. The project and its history are unaffected. WithLAYERFS_TRACE set, save the daemon's log first: docker logs on the container id in$LAYERFS_HOME/_sandboxes/<id>/container.

ExampleClose when the agent is done
// 1. Close is refused while a Workspace is still mounted.
await layerfs.sandbox.close("builder")
LayerFSError: MountsActive
// 2. Unmount every Workspace, then close. The container stops and the name is free again.
await layerfs.sandbox.unmount("builder", "/workspaces/1")
await layerfs.sandbox.close("builder")
{ kind: "Unmounted" } { kind: "Closed" }

Errors and results

Agent skillSKILL.md · Rulesrules/conflicts.mdrules/lww.md

A refused call throws a LayerFSError with a stable code, a message for people and a structured detail. The code alone decides; isRefusal(error, code) tests for one.

import { isRefusal } from "@layerfs/sdk";

try {
  await layerfs.sandbox.unmount(id, path);
} catch (e) {
  if (isRefusal(e, "Dirty")) console.log("uncommitted:", e.detail.changed);
  else throw e;
}

Read before you retry. Every call is attempted once; LayerFS never retries or resends on its own. OnUnknown or Failed the outcome is uncertain: stop, check with branch.list_commitsand fs.read, and report.

CodeMeansDo
ConflictThe same path changed on both sides; nothing committedRead both sides, fix, try again. For a Workspace, fix the markers
HeadMovedThe head moved during the call; nothing publishedLook at the Branch, then decide
DirtyUnmount with uncommitted changesCommit, or discard deliberately
BusyAnother process holds the Sandbox or lockRun steps one after another
NoSuchPath, NoSuchBranch, NoSuchCommit, NoSuchProject, NoSuchSandboxThe thing named doesn't existList it: fs.list, project.list_branches
NoMatch, AmbiguousMatchfs.edit found zero or several matchesRead the file again; widen oldText
IsDirectory, NotDirectory, Binary, NotTextWrong kind of path or contentUse a Sandbox command for binary work
Exists, NameTaken, ReservedNameThe name is taken or reservedUse it, or pick another
ReadOnlyA write to mainWrite on a Branch, then publish
MountsActive, NotMountedClose with mounts, or a path not mountedUnmount first; use the path from mount
TooLargeThe request didn't fit; nothing sentA smaller call, or one Sandbox step
UnsupportedStore calls on an unsupported platformRun on Linux with a local Store
InvalidInputA bad argumentRead message
Unknown, FailedThe outcome is uncertain or failedStop, check, report

Results

  • Every result has a kind: Committed, UpToDate, Forked, Merged, Added, Unmounted, Closed, exit:<code>, or ok for reads.
  • Messages are optional UTF-8, at most 4096 bytes; newline and tab are allowed. A message is part of the Commit's identity, and a message alone never makes a Commit.
  • Ids are hex strings. A Commit or Layer id is a ref you can read, fork or diff.
  • Pages are { items, hasMore }; ask for the next at pagePos + items.length.