Docs menu

SDK reference

Agent SDK

@layerfs/agent is everything an agent does on one project: 32 calls in six groups. Every call takes positional arguments and returns a Promise; none takes an options object except where a signature below shows one. Each call is also one shell command, shown beside its signature: thelayerfs command makes the same call and prints its answer as JSON.

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 group, linked under each heading below. Where the skill and this page differ, this page follows the SDK source.

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 Workspace 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
Bashworkspace.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 Workspace for commands: installs, builds, tests, generators, and changes across many files where one command beats many calls. It lives in a Sandbox you name: start one with sandbox.create, mount as many Workspaces there as you need, and close it when you're done.
  • 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

A client is one project, opened in this process. LayerFS.open connects to a project that exists;LayerFS.init creates it first. Both answer a ready client. There is no new LayerFS(…), and no options argument: every setting comes from the environment, read once when the client is made.

import { LayerFS, isRefusal } from "@layerfs/agent";

const layerfs = await LayerFS.open("shop");   // opens the project's Store; NoSuchProject if it doesn't exist
try {
  // … Store calls and Workspace calls …
} finally {
  await layerfs.close();                      // releases this process's Store; Workspaces stay mounted
}

LayerFS.open

SDK

LayerFS.open(name)

CLI

no command: every layerfs command opens the project and closes it

Returnsan open client

RefusesNoSuchProject, InvalidInput (a bad name, or an options object), Unsupported (Store not on local Linux)

Opens the project's Store and looks the project up once, so a missing project is refused at once and nothing stays open. The name is 1 to 63 of a-z 0-9 _ - ., starting and ending with a letter or digit.

LayerFS.init

SDK

LayerFS.init(name, sourcePath, timeout = 300000)

CLI

layerfs init <name> <source-dir> [--timeout <n>]

Returnsan open client; Init's answer is on its created: { kind: "Initialized", project, layer, root, store }, where layer is main's first Layer

RefusesExists (already initialized: open it), InvalidInput (a bad path, name or timeout, or a source entry it can't store; detail names it), Failed with Deadline in the message (it ran past timeout), Unknown / Failed (stop and report)

Copies a folder into a new Store as main @ L0, once. Everything is imported, including.git and ignored files. timeout is in milliseconds, a positive whole number; there is no unlimited option, and a large tree takes a while. store is the installed Store file. Init's temporary sealed copy is deleted once the install is recorded; if that delete fails, the answer carriesleftover: { path, message } and the project is still ready.

SDKInit once, then open

// 1. Init reads the source tree once, ignored files included, makes it
//    main's first Layer, and answers an open client.
const layerfs = await LayerFS.init("shop", "/work/shop")
layerfs.created
// → { kind: "Initialized", project: "shop", layer: "324a…", root: "e34e…", store: "/layerfs-store/global/store.sqlite" }

// 2. A second Init of the same name is refused. The project already exists:
//    open it.
await LayerFS.init("shop", "/work/shop")
// → LayerFSError: Exists

// 3. Later processes open the project without reading the source again.
const later = await LayerFS.open("shop")
await later.project.list_branches()
// → { items: [{ name: "main", id: null, head: null, … }], hasMore: false }
layerfsInit once, then open
# 1. Init reads the source tree once, ignored files included, makes it main's first Layer, and answers an open client.
layerfs init shop /work/shop
{"kind":"Initialized","project":"shop","layer":"324a…","root":"e34e…","store":"/layerfs-store/global/store.sqlite"}
# 2. A second Init of the same name is refused. The project already exists: open it.
layerfs init shop /work/shop
{"code":"Exists","message":"…","detail":null}
exit 125
# 3. Every later command opens the project itself, without reading the source again.
layerfs project list_branches
{"items":[{"name":"main","id":null,"head":null,…}],"hasMore":false,"nextPagePos":null}

layerfs.close

SDK

layerfs.close()

CLI

no command: every layerfs command opens the project and closes it

Returnsnothing

Releases the Store; the process's last client of it closes it. Workspaces stay mounted. Every later call on the client is refused InvalidInput, and a second close does nothing.await using layerfs = await LayerFS.open("shop") closes it at the end of the block.

Environment

Set these for every process of one run, with the same values. The SDK and the layerfs command read only these LAYERFS_ names; an integration that can set only names with its own prefix maps them to these in its own wrapper.

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: global/store.sqlite in the project's volume
LAYERFS_DOCKERDocker Engine socket; default /var/run/docker.sock
LAYERFS_IMAGERuntime image digest, sha256:…, that a new Sandbox runs
LAYERFS_VOLUMEExisting Docker volume shared by the project's daemons
LAYERFS_DAEMONHost path to the built Linux daemon
LAYERFS_CONTAINER_PREFIXOptional: names containers <prefix><project>-<n>; default layerfs-
LAYERFS_DAEMON_HOSTOptional: the host this process dials for a daemon, such as host.docker.internal from inside a container
LAYERFS_STORE_POLICYOptional, Init only: JSON { smallFileThreshold, wholeFileDepth, chunkDepth, metadataDepth }
LAYERFS_NATIVEOptional: the native addon; default api/binding/layerfs.node
LAYERFS_WORKER_ENDPOINTmacOS and Windows: the endpoint file of the LayerFS home's worker, <home>/worker/endpoint.json, which then makes every call. Any integration may set it
LAYERFS_TRACE, LAYERFS_TRACE_STEP, LAYERFS_TRACE_STARTOptional: a file that gets one JSON line per call, a step label for each line (read at every call), and 1 for a start line before each call
LAYERFS_PROJECTThe layerfs command only: the project, when --project isn't given

project

Agent skillmethods/project.md

project.list_branches

SDK

list_branches(pageSize = 50, pagePos = 0)

CLI

layerfs project list_branches [--page-size <n>] [--page-pos <n>]

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.

SDKCheck 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 }
layerfsCheck for a Branch before forking
# 1. Branches come in pages, main first.
layerfs project list_branches --page-size 50
{"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.
layerfs project list_branches | jq -e '.items[] | select(.name == "search")' >/dev/null \
|| layerfs branch fork search
{"kind":"Forked","branch":"search","head":null,…}

project.list_sandboxes

SDK

list_sandboxes(pageSize = 50, pagePos = 0)

CLI

layerfs project list_sandboxes [--page-size <n>] [--page-pos <n>]

Returnsa page of { sandbox, name, image, workspaces }, lowest number first

The project's running Sandboxes, from their records. It starts nothing and dials no daemon; a Sandbox that closed itself or whose container is gone is left out.

const { items } = await layerfs.project.list_sandboxes();
for (const s of items) console.log(s.sandbox, s.name, s.workspaces);   // "1" "layerfs-shop-1" 2

branch

Agent skillmethods/branch.md

branch.fork

SDK

fork(name, fromRef = "main")

CLI

layerfs branch fork <name> [<from-ref>]

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.

SDKFork 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…" }
layerfsFork from main
# 1. A fork from main starts at its newest Layer and has no Commit yet.
layerfs branch fork cart
{"kind":"Forked","branch":"cart","base":"324a…","head":null}
# 2. Its first write makes its first Commit.
printf 'Cart rewrite\n' | layerfs fs write cart /NOTES.md -m "Add notes"
{"kind":"Committed","branch":"cart","head":"c1a0…",…}

SDKFork 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…" }
layerfsFork a fork, or any Commit
# 1. Fork from another Branch's head. Nothing is copied, at any depth.
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.
layerfs branch fork cart.retry c1a0…
{"kind":"Forked","branch":"cart.retry","head":"c1a0…",…}

SDKA 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
layerfsA name that is taken
# 1. Branch names are unique, and main is reserved.
layerfs branch fork cart
layerfs branch fork main
{"code":"NameTaken","message":"…"}
exit 125
{"code":"ReservedName","message":"…"}
exit 125

branch.list_commits

SDK

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

CLI

layerfs branch list_commits <branch> [--page-size <n>] [--page-pos <n>]

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"));

SDKPage 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 }
layerfsPage through history
# 1. Commits come newest first, along first parents.
layerfs branch list_commits cart --page-size 2
{"items":[{"id":"c3c2…","message":"Add the route",…},{"id":"c2b1…","message":"Tax 8%",…}],"hasMore":true,"nextPagePos":2}
# 2. Ask for the next page at nextPagePos.
layerfs branch list_commits cart --page-size 2 --page-pos 2
{"items":[{"id":"c1a0…","message":"Add notes",…}],"hasMore":false,"nextPagePos":null}

branch.diff_commits

SDK

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

CLI

layerfs branch diff_commits <from-commit> <to-commit> [--page-size <n>] [--page-pos <n>] [--cursor <text>] [--no-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 });

SDKWhat 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 }
layerfsWhat did the last step change?
# 1. Take the two newest Commits.
layerfs branch list_commits cart --page-size 2 \
| jq -r '.items[] | "\(.id) \(.message)"'
c3c2… Add the route
c2b1… Tax 8%
# 2. Diff them: each changed path with its kind and line hunks.
layerfs branch diff_commits c2b1… c3c2…
{"items":[{"path":"/src/routes/cart.js","kind":"Added",…},{"path":"/src/basket.js","kind":"Removed",…}],"hasMore":false,…}

branch.diff_branches

SDK

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

CLI

layerfs branch diff_branches <from-branch> <to-branch> [--page-size <n>] [--page-pos <n>] [--cursor <text>] [--no-hunks]

Returnsthe same shape as diff_commits

RefusesInvalidInput (a Commit id), NoSuchBranch

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

SDKList 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 }
layerfsList a big change by path
# 1. --no-hunks lists paths without their contents, so a page fills with paths.
layerfs branch diff_branches main cart --page-size 200 --no-hunks
{"items":[{"path":"/src/cart.js","kind":"Modified","hunks":null},… 200],"hasMore":true,"nextCursor":"/src/k.js"}
# 2. Pass nextCursor back as --cursor to resume after the last path, instead of re-walking the first pages.
layerfs branch diff_branches main cart --page-size 200 --no-hunks --cursor /src/k.js
{"items":[… 37],"hasMore":false,…}

branch.merge

SDK

merge(a, b, name, message?, units?)

CLI

layerfs branch merge <a> <b> <name> [-m <text>] [--units <json>]

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.

units is an optional list of merge units, each a /.lww-style pattern or{ name, patterns }. When both sides changed a unit, the whole unit comes from the side with the later write, with no text merge inside it; the result's units lists what each one kept and dropped.

const m = await layerfs.branch.merge("cart", "main", "cart-release", "Merge main before publish");
console.log(m.branch, m.fastForward);   // "cart-release" false

SDKA 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…" }
layerfsA 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.
layerfs branch merge cart cart.1 cart.next -m "Keep cart.1"
{"kind":"Merged","fastForward":true,"branch":"cart.next","head":"c4d3…",…}

SDKBring 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: [] }
layerfsBring 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.
layerfs branch merge cart main cart-release -m "Merge main"
{"kind":"Merged","fastForward":false,"branch":"cart-release","head":"c5e4…","conflicts":[],"lww":[],…}

SDKA 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", … }
layerfsA conflict creates nothing
# 1. Both sides changed the same lines of src/cart.js.
layerfs branch merge cart.a cart.b cart.ab
{"code":"Conflict","message":"…","detail":{"sources":…,"mergeBase":"c3c2…","conflicts":[{"path":"/src/cart.js",…}],"lww":[]}}
exit 125
# 2. Make the sides agree on those lines (here cart.b takes cart.a's), then merge again.
layerfs fs edit cart.b /src/cart.js --old-from b.txt --new-from a.txt
layerfs branch merge cart.a cart.b cart.ab
{"kind":"Committed","branch":"cart.b",…}
{"kind":"Merged","fastForward":false,"branch":"cart.ab",…}

branch.publish

SDK

publish(branch, commit?)

CLI

layerfs branch 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);

SDKPublish 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 }
layerfsPublish a Branch
# 1. The Branch's head becomes main's next Layer.
layerfs branch publish cart-release
{"kind":"Added","publishedLayer":{"id":"l2d0…"},"basedOn":"l1c3…","overwroteCount":0,…}
# 2. New work forks from the new Layer.
layerfs branch fork cart-2
{"kind":"Forked","branch":"cart-2","base":"l2d0…","head":null}

SDKPublishing 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 }
layerfsPublishing overwrites
# 1. search was published as L1 after cart forked from L0. Publishing cart directly skips L1, and says so.
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.
layerfs branch merge cart main cart-release
layerfs branch publish cart-release
{"kind":"Merged",…}
{"kind":"Added","overwroteCount":0,…}

SDKPublish 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…" }, … }
layerfsPublish an earlier Commit
# 1. Name a Commit to publish it instead of the head.
layerfs branch publish cart c2b1…
{"kind":"Added","source":{"branch":"cart","commit":"c2b1…"},…}

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

SDK

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

CLI

layerfs fs read <ref> <path> [--start-line <n>] [--end-line <n>]

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;
}

SDKRead 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 }
layerfsRead a long file in windows
# 1. A read returns up to 2000 lines. A longer file says where the next window starts.
layerfs fs read cart /src/cart.js
{"kind":"ok","text":"…","startLine":1,"endLine":2000,"truncated":true,"nextLine":2001,"size":91204,…}
# 2. Ask again from nextLine until it is null.
layerfs fs read cart /src/cart.js --start-line 2001
{"kind":"ok","text":"…","startLine":2001,"endLine":2400,"truncated":false,"nextLine":null,…}

SDKA 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 }
layerfsA few lines, at any ref
# 1. Lines are 1-based and inclusive; jq -r prints just the text.
layerfs fs read c2b1… /src/cart.js --start-line 1 --end-line 5 | jq -r .text
import …
…

SDKNot 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
layerfsNot a text file
# 1. A file with NUL bytes or invalid UTF-8 is refused. Use a Sandbox command for binary work.
layerfs fs read cart /public/logo.png
{"code":"Binary","message":"…"}
exit 125

fs.list

SDK

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

CLI

layerfs fs list <ref> [<path>] [--page-size <n>] [--page-pos <n>]

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

RefusesNotDirectory, NoSuchPath

One folder, names in byte order.

fs.stat

SDK

stat(ref, path)

CLI

layerfs fs stat <ref> <path>

Returns{ type, inode, size }

RefusesNoSuchPath

fs.grep

SDK

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

CLI

layerfs fs grep <ref> <pattern> [<path>] [--page-size <n>] [--page-pos <n>]

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.

SDKFind 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: [] }
layerfsFind every use of a name
# 1. Search the contents under a folder. Each match gives its path, line and text.
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

SDK

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

CLI

layerfs fs glob <ref> <pattern> [<path>] [--page-size <n>] [--page-pos <n>]

Returnsa page of absolute path strings

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

SDKCollect 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)
}
layerfsCollect every file of a kind
# 1. ** matches any number of folders, including none. Quote the pattern so the shell leaves it alone.
layerfs fs glob cart 'src/**/*.js' / --page-size 1000
{"items":["/src/cart.js","/src/routes/cart.js","/src/server.js"],"hasMore":false,"nextPagePos":null}
# 2. While hasMore is true, ask again at nextPagePos.
layerfs fs glob cart 'src/**/*.js' / --page-size 1000 --page-pos 1000

fs.diff_files

SDK

diff_files(leftRef, leftPath, rightRef, rightPath)

CLI

layerfs fs diff_files <left-ref> <left-path> <right-ref> <right-path>

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

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

SDKOne 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;" }] }
layerfsOne file, two refs
# 1. Compare a file on main with the same file on a Branch.
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

SDK

blame(ref, path, fromLine = 1)

CLI

layerfs fs blame <ref> <path> [--from-line <n>]

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.

SDKWho 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 }
layerfsWho wrote each line
# 1. Each line names the Commit, and so the tool call, that last changed it. --text prints it for reading.
layerfs fs blame cart /src/cart.js --text
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 write's body travels in the call itself, since Store calls run in your process, and is written a record at a time, so no file size is capped. A Workspace mounted earlier doesn't see a write until you mount again or its next Commit merges it in.

fs.write

SDK

write(branch, path, content, message?)

CLI

layerfs fs write <branch> <path> [-m <text>] [--from <file>]

ReturnsCommitted or UpToDate

RefusesIsDirectory

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

SDKWrite, 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…" }
layerfsWrite, then write the same again
# 1. A write takes the file from stdin and replaces the whole file, as one Commit.
printf 'export default {};\n' | layerfs fs write cart /src/routes/cart.js -m "Add route"
{"kind":"Committed","branch":"cart","head":"c4d3…",…}
# 2. Writing what is already there changes nothing, so no Commit is made.
printf 'export default {};\n' | layerfs fs write cart /src/routes/cart.js
{"kind":"UpToDate","branch":"cart","head":"c4d3…",…}

fs.edit

SDK

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

CLI

layerfs fs edit <branch> <path> [-m <text>] [--old <text>] [--old-from <file>] [--new <text>] [--new-from <file>]

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.

SDKReplace 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…" }
layerfsReplace one exact match
# 1. --old must appear exactly once. The edit is one Commit.
layerfs fs edit cart /src/cart.js \
--old "const TAX = 0.07;" --new "const TAX = 0.08;" -m "Tax 8%"
{"kind":"Committed","branch":"cart","head":"c2b1…",…}

SDKNo 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
layerfsNo match, or more than one
# 1. Text that isn't there is refused.
layerfs fs edit cart /src/cart.js --old "const TAX = 0.07;" --new "…"
{"code":"NoMatch","message":"…"}
exit 125
# 2. So is text that appears more than once. Read the file again and widen --old until it's unique.
layerfs fs edit cart /src/cart.js --old qty --new quantity
{"code":"AmbiguousMatch","message":"…"}
exit 125

SDKAnother 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…" }
layerfsAnother agent got there first
# 1. Agent A edits cart.js and lands.
layerfs fs edit cart /src/cart.js --old "price * qty" --new "cents(price) * qty" # agent A
{"kind":"Committed","head":"c7a6…",…}
# 2. Agent B changes the same file from an older head. Refused; nothing is committed or retried.
layerfs fs edit cart /src/cart.js --old "price * qty" --new "price * quantity" # agent B
{"code":"Conflict","message":"…","detail":{"conflicts":["/src/cart.js"],"head":"c7a6…"}}
exit 125
# 3. B reads the new head and edits again.
layerfs fs read cart /src/cart.js > /tmp/cart.json # agent B
layerfs fs edit cart /src/cart.js \
--old "cents(price) * qty" --new "cents(price) * quantity"
{"kind":"Committed","head":"c8b7…",…}

fs.apply

SDK

apply(branch, changes, message?)

CLI

layerfs fs apply <branch> [-m <text>] [--from <file>]

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

SDKSeveral 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…" }
layerfsSeveral changes, one Commit
# 1. apply reads its changes as JSON from stdin, runs them in order and publishes once.
layerfs fs apply cart -m "Add the route" <<'EOF'
[{ "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" }]
EOF
{"kind":"Committed","head":"c3c2…",…}

SDKAll 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 }
layerfsAll or nothing
# 1. If any change fails, none land. detail.index is the zero-based change that failed.
echo '[{"op":"write","path":"/a.txt","content":"a"},{"op":"rm","path":"/missing.txt"}]' \
| layerfs fs apply cart
{"code":"NoSuchPath","message":"…","detail":{"index":1}}
exit 125

fs.mkdir

SDK

mkdir(branch, path, message?)

CLI

layerfs fs mkdir <branch> <path> [-m <text>]

ReturnsCommitted, or UpToDate for an existing folder

RefusesNotDirectory

Creates a folder and any missing parents.

fs.rm

SDK

rm(branch, path, message?)

CLI

layerfs fs rm <branch> <path> [-m <text>]

ReturnsCommitted

RefusesNoSuchPath

Removes a file or a whole folder tree.

SDKRemove, 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, … }
layerfsRemove, and still read the old version
# 1. rm removes a file or a whole folder as one Commit.
layerfs fs rm cart /src/legacy -m "Drop legacy code"
{"kind":"Committed","head":"c5e4…",…}
# 2. Older Commits still have it.
layerfs fs read c4d3… /src/legacy/basket.js
{"kind":"ok","text":"…","startLine":1,…}

fs.cp

SDK

cp(branch, from, to, message?)

CLI

layerfs fs cp <branch> <from-path> <to-path> [-m <text>]

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

SDK

mv(branch, from, to, message?)

CLI

layerfs fs mv <branch> <from-path> <to-path> [-m <text>]

ReturnsCommitted

Refusesthe same as fs.cp

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

workspace

Agent skillmethods/workspace.md

A Workspace is a complete, writable view of a Branch where ordinary commands run. It is{ sandbox, path }: the number of the Sandbox that holds it and its path there. Any later process with the same LAYERFS_HOME can use it.

  • A mount names its Sandbox. A project's Sandboxes are numbered 1, 2, …; each is a container with one daemon and holds up to 16 Workspaces of any Branches. sandbox.create is the only call that starts one; mount(sandbox, branch) mounts in the Sandbox it names and never starts one. There is no placement.
  • Each mount is a new Workspace. Pass the Workspace that mount answered to exec, commit and unmount; never build one yourself. Commands use relative paths from the Workspace root.
  • 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.
  • Unmount in a finally. Nothing unmounts a Workspace for you. A Sandbox closes itself two minutes after its last Workspace is unmounted; if its container dies, its Workspaces are gone.

One Bash call as one Workspace, in a Sandbox from sandbox.create:

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

workspace.mount

SDK

mount(sandbox, branch)

CLI

layerfs workspace mount <sandbox> <branch>

Returnsthe Workspace, { sandbox, path, image }, such as { sandbox: "1", path: "/workspaces/1", image: "sha256:…" }

RefusesNoSuchBranch (main can't be mounted: fork it), NoSuchSandbox (not running, or it closed itself: sandbox.create a new one), Capacity (it holds 16 Workspaces: create another, or unmount ones you're done with), InvalidInput (a bad Sandbox number, or an options object: there are none), Failed

Mounts a Workspace on the Branch head at this moment, in the running Sandbox sandbox names, such as"1" from sandbox.create or project.list_sandboxes, and nowhere else. It doesn't scan or copy the tree, and never starts a Sandbox. The Sandbox fixes the image.

SDKMount a Branch

// 1. Each mount is a new Workspace in the Sandbox it names. It binds the
//    whole tree without copying it.
await layerfs.workspace.mount("1", "cart")
await layerfs.workspace.mount("1", "search")
// → { sandbox: "1", path: "/workspaces/1", image: "sha256:…" }
// → { sandbox: "1", path: "/workspaces/2", image: "sha256:…" }

// 2. main can't be mounted. Fork it and mount the fork.
await layerfs.workspace.mount("1", "main")
// → LayerFSError: NoSuchBranch

// 3. A mount never starts a Sandbox. One that closed itself is refused;
//    create a new one and mount there.
await layerfs.workspace.mount("3", "cart")
const own = await layerfs.sandbox.create()
await layerfs.workspace.mount(own.sandbox, "cart")
// → LayerFSError: NoSuchSandbox
// → { sandbox: "3", image: "sha256:…" }
// → { sandbox: "3", path: "/workspaces/1", image: "sha256:…" }
layerfsMount a Branch
# 1. Each mount is a new Workspace in the Sandbox it names, printed with its handle as ws.
layerfs workspace mount 1 cart
layerfs workspace mount 1 search
{"sandbox":"1","path":"/workspaces/1","image":"sha256:…","ws":"1:/workspaces/1"}
{"sandbox":"1","path":"/workspaces/2","image":"sha256:…","ws":"1:/workspaces/2"}
# 2. main can't be mounted. Fork it and mount the fork.
layerfs workspace mount 1 main
{"code":"NoSuchBranch","message":"…"}
exit 125
# 3. A mount never starts a Sandbox. One that closed itself is refused; create a new one and mount there.
layerfs workspace mount 3 cart
layerfs sandbox create
layerfs workspace mount 3 cart
{"code":"NoSuchSandbox","message":"… create one with sandbox.create() …"}
exit 125
{"sandbox":"3","image":"sha256:…"}
{"sandbox":"3","path":"/workspaces/1","image":"sha256:…","ws":"3:/workspaces/1"}

workspace.exec

SDK

exec(ws, command, message?, { stdout, stderr }?)

CLI

layerfs workspace exec <ws> <command> [-m <text>] [--stream]

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

RefusesNotMounted, NoSuchSandbox (its container is gone: mount again), 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. Output comes back whole, sotruncated is always false; send a long log to /tmp and read its end. There is no timeout, so wrap long commands: timeout 110 npm test. The message labels the run and creates no history.

With sinks, Node Writable streams such as process.stdout, the named streams' bytes are written to them as they arrive instead: the call holds one page of output however long it is, the result's field for each is null, and it carries stdoutBytes and stderrBytes. layerfs workspace exec --streamuses this and exits with the command's own code.

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

// Long output: write it to /tmp, outside the Workspace, and read the end.
await layerfs.workspace.exec(ws, "timeout 110 npm run build >/tmp/build.log 2>&1; echo $? >/tmp/build.code");
const tail = await layerfs.workspace.exec(ws, "cat /tmp/build.code; tail -n 60 /tmp/build.log");

SDKRun a command

// 1. The command runs with bash -c in the Workspace. Bound it with timeout:
//    LayerFS never stops a command itself.
await layerfs.workspace.exec(ws, "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.workspace.commit(ws, "npm test")
// → { kind: "UpToDate", head: "c3c2…" }
layerfsRun a command
# 1. The command runs with bash -c in the Workspace. Bound it with timeout: LayerFS never stops a command itself.
layerfs workspace exec 1:/workspaces/1 "timeout 110 npm test 2>&1" -m "Run tests"
{"kind":"exit:0","exitCode":0,"stdout":"# pass 3 …","stderr":"","truncated":false,"message":"Run tests"}
# 2. A run makes no history. Commit afterwards to save what it changed.
layerfs workspace commit 1:/workspaces/1 -m "npm test"
{"kind":"UpToDate","head":"c3c2…",…}

SDKLong output

// 1. Send long output to a file in /tmp, outside the Workspace, so it isn't
//    committed.
await layerfs.workspace.exec(ws,
  "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.workspace.exec(ws,
  "cat /tmp/build.code; tail -n 60 /tmp/build.log")
// → { exitCode: 0, stdout: "0\n… built in 4.2s" }
layerfsLong output
# 1. Send long output to a file in /tmp, outside the Workspace, so it isn't committed.
layerfs workspace exec 1:/workspaces/1 \
"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. --stream prints the output itself and exits with the command's code.
layerfs workspace exec 1:/workspaces/1 "cat /tmp/build.code; tail -n 60 /tmp/build.log" --stream
0
… built in 4.2s

workspace.commit

SDK

commit(ws, message?, force = false)

CLI

layerfs workspace commit <ws> [-m <text>] [--force]

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

RefusesConflict, HeadMoved, NotMounted

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.

SDKCommit what a command changed

// 1. The Commit saves the whole Workspace as it stands, including files
//    from background processes.
await layerfs.workspace.exec(ws, "timeout 110 npm install dayjs")
await layerfs.workspace.commit(ws, "Add dayjs")
// → { kind: "Committed", head: "c4d3…", commit: { message: "Add dayjs", … } }

// 2. With nothing changed, it answers UpToDate and makes no Commit.
await layerfs.workspace.commit(ws, "Again")
// → { kind: "UpToDate", head: "c4d3…" }
layerfsCommit what a command changed
# 1. The Commit saves the whole Workspace as it stands, including files from background processes.
layerfs workspace exec 1:/workspaces/1 "timeout 110 npm install dayjs" --stream
layerfs workspace commit 1:/workspaces/1 -m "Add dayjs"
added 1 package …
{"kind":"Committed","head":"c4d3…","commit":{"message":"Add dayjs",…},…}
# 2. With nothing changed, it answers UpToDate and makes no Commit.
layerfs workspace commit 1:/workspaces/1 -m "Again"
{"kind":"UpToDate","head":"c4d3…",…}

SDKThe head moved meanwhile

// 1. Another agent committed to cart. This Commit changed other files, so
//    it merges and lands on top.
await layerfs.workspace.commit(ws, "Tax 9%")
// → { kind: "Committed", head: "c6f5…", merged: { from: "c4d3…", head: "c5e4…" } }
layerfsThe head moved meanwhile
# 1. Another agent committed to cart. This Commit changed other files, so it merges and lands on top.
layerfs workspace commit 1:/workspaces/1 -m "Tax 9%"
{"kind":"Committed","head":"c6f5…","merged":{"from":"c4d3…","head":"c5e4…"},…}

SDKA 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.workspace.commit(ws, "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.workspace.exec(ws, "…edit src/cart.js…")
await layerfs.workspace.commit(ws, "Rename qty")
// → { kind: "Committed", head: "c9c8…" }
layerfsA conflict in a Workspace
# 1. The same file changed on the new head. Nothing is published; marker blocks are written into the file.
layerfs workspace commit 1:/workspaces/1 -m "Rename qty"
{"code":"Conflict","message":"…","detail":{"head":"c8b7…","conflicts":[{"path":"/src/cart.js","kind":"content",…}]}}
exit 125
# 2. Fix the markers with ordinary commands. A Commit is refused while a complete marker block remains.
layerfs workspace exec 1:/workspaces/1 "…edit src/cart.js…"
layerfs workspace commit 1:/workspaces/1 -m "Rename qty"
{"kind":"exit:0",…}
{"kind":"Committed","head":"c9c8…",…}

workspace.unmount

SDK

unmount(ws, discard = false)

CLI

layerfs workspace unmount <ws> [--discard]

Returns{ kind: "Unmounted" }

RefusesDirty (detail.changed lists uncommitted paths), Busy (a background process still runs there), NotMounted

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

SDKUncommitted changes

// 1. Unmounting with changes not yet committed is refused, so nothing is
//    lost by accident.
await layerfs.workspace.unmount(ws)
// → LayerFSError: Dirty
//   detail: { changed: ["/scratch.txt"] }

// 2. Commit them, or discard them on purpose.
await layerfs.workspace.unmount(ws, true)
// → { kind: "Unmounted" }
layerfsUncommitted changes
# 1. Unmounting with changes not yet committed is refused, so nothing is lost by accident.
layerfs workspace unmount 1:/workspaces/1
{"code":"Dirty","message":"…","detail":{"changed":["/scratch.txt"]}}
exit 125
# 2. Commit them, or discard them on purpose.
layerfs workspace unmount 1:/workspaces/1 --discard
{"kind":"Unmounted"}

sandbox

Agent skillmethods/sandbox.md

Every Workspace lives in a Sandbox you name, so these calls come first. sandbox.create is the only call that starts one. Reuse a Sandbox with room (project.list_sandboxes shows how many Workspaces each holds), and close it when you're done. Another agent can mount in your Sandbox by naming it; close then waits on that Workspace's unmount.

const own = await layerfs.sandbox.create();                 // { sandbox: "2", image }
try {
  const ws = await layerfs.workspace.mount(own.sandbox, "cart");
  try {
    const run = await layerfs.workspace.exec(ws, "timeout 110 npm run build", "step 20");
    await layerfs.workspace.commit(ws, `step 20: npm run build (exit ${run.exitCode})`);
  } finally {
    await layerfs.workspace.unmount(ws);
  }
} finally {
  await layerfs.sandbox.close(own.sandbox);
}

sandbox.create

SDK

create({ image } = {})

CLI

layerfs sandbox create [--image <text>]

Returns{ sandbox, image }: its number, to pass to workspace.mount, and its image

RefusesFailed (with detail.removed: true nothing of it is left; detail.log has its daemon's last lines), Unknown (an earlier start's outcome is unknown: report it), Busy (the project's lock held for 60 s), InvalidInput (a missing setting, or an image that isn't a digest)

Starts the project's next Sandbox, the lowest free number, and waits for its daemon. It first reclaims the project's Sandboxes that closed themselves, so their numbers are used again. image is a digest,sha256:…, default LAYERFS_IMAGE; every Workspace mounted there runs on it. Like any Sandbox, it closes itself two minutes after its last Workspace is unmounted.

sandbox.list_workspaces

SDK

list_workspaces(sandbox, pageSize = 50, pagePos = 0)

CLI

layerfs sandbox list_workspaces <sandbox> [--page-size <n>] [--page-pos <n>]

Returnsa page of { sandbox, path, branch, base, activity, changed, headMovedSinceMount, pendingMerge }, plus a version

RefusesNoSuchSandbox, InvalidInput

The mounted Workspaces of one running Sandbox, from its daemon. Use it to clear leftovers: unmount only Workspaces that are Idle, have nothing uncommitted and that no agent is keeping on purpose.

sandbox.close

SDK

close(sandbox)

CLI

layerfs sandbox close <sandbox>

Returns{ kind: "Closed", sandbox }

RefusesMountsActive (detail.workspaces lists them), NoSuchSandbox (already closed), Failed / Unknown

Stops and deletes the container now, each step attempted once, then its records. The project and its history are unaffected.

SDKClose your own Sandbox

// 1. Close is refused while a Workspace is still mounted there.
await layerfs.sandbox.close(own.sandbox)
// → LayerFSError: MountsActive
//   detail: { workspaces: [{ sandbox: "2", path: "/workspaces/1" }] }

// 2. Unmount it, then close. The container stops and is deleted; the
//    project's history is unaffected.
await layerfs.workspace.unmount(ws)
await layerfs.sandbox.close(own.sandbox)
// → { kind: "Unmounted" }
// → { kind: "Closed", sandbox: "2" }
layerfsClose your own Sandbox
# 1. Close is refused while a Workspace is still mounted there.
layerfs sandbox close 2
{"code":"MountsActive","message":"…","detail":{"workspaces":[{"sandbox":"2","path":"/workspaces/1"}]}}
exit 125
# 2. Unmount it, then close. The container stops and is deleted; the project's history is unaffected.
layerfs workspace unmount 2:/workspaces/1
layerfs sandbox close 2
{"kind":"Unmounted"}
{"kind":"Closed","sandbox":"2"}

The layerfs command

layerfs makes one @layerfs/agent call per command, so an agent whose only tool is Bash can use LayerFS without writing a script. It is only a wrapper: each command opens the project withLayerFS.open, makes exactly one call through the SDK's public API, prints the answer and closes the client. It adds no check or retry of its own, reads the same settings, and follows the same platform rules (in this process on Linux, through the LayerFS home's worker on macOS and Windows). Every call above shows its command beside its signature.

It is the api/agent/cli/ folder of a LayerFS checkout, not published to npm. It needs Node 20 or newer and the SDK's native addon (build:native, or LAYERFS_NATIVE).

npm --prefix "$LAYERFS_REPO/api" install              # links the packages; downloads nothing
npm --prefix "$LAYERFS_REPO/api" run build:native     # the SDK's native addon, on the host that loads it
npm --prefix "$LAYERFS_REPO/api/agent/cli" install    # links the command to @layerfs/agent
"$LAYERFS_REPO/api/agent/cli/src/layerfs.js" help     # or npm link in api/agent/cli, then: layerfs help

A short session, one call per line:

export LAYERFS_PROJECT=shop                        # or --project shop on each command
layerfs branch fork cart
printf '# Shop\n' | layerfs fs write cart /README.md -m readme
layerfs sandbox create                             # {"sandbox":"1","image":"sha256:…"}
layerfs workspace mount 1 cart                     # {"sandbox":"1","path":"/workspaces/3",…,"ws":"1:/workspaces/3"}
layerfs workspace exec 1:/workspaces/3 "timeout 110 npm test" --stream
layerfs workspace commit 1:/workspaces/3 -m "step 1: npm test"
layerfs workspace unmount 1:/workspaces/3
layerfs sandbox close 1

How a call becomes a command

PartRule
Namefamily.call is layerfs <family> <call>, with the SDK's names (branch list_commits; list-commits is the same). LayerFS.init is layerfs init
Project--project <name>, else LAYERFS_PROJECT. The command reads no other prefix
ArgumentsThe SDK's positional arguments, in its order. An optional one left out isn't sent, so the SDK applies its own default
OptionsThe SDK's remaining arguments as flags: --page-size <n>, --start-line <n>, --no-hunks (sends hunks: false), --image <digest>. A value is --name value or --name=value; -m <text> is the Commit message, the only short option. A whole number is digits only, --units is JSON, and a flag takes no value. An unknown or repeated option is refused; -- ends the options, so workspace exec 1:/workspaces/3 -- --version runs --version
GlobalAnywhere before --: --project <name>, --text (readable output instead of JSON), -h/--help, --version
WorkspaceWritten <sandbox>:<path>, such as 1:/workspaces/3. workspace mount prints it as ws beside the SDK's { sandbox, path, image }
Bodiesfs write reads the file's bytes from stdin or --from <file>; fs apply reads its JSON list of changes the same way; fs edit takes --old or --old-from, and --new or --new-from. Input is read before anything opens
Execworkspace exec prints the SDK's result, with the command's exitCode inside, and exits 0. With --stream it passes the command's stdout and stderr through as they arrive and exits with the command's own code

Output and exit codes

ExitMeansOutput
0The call succeededstdout: the result as one line of JSON; --text prints readable key: value lines
2The command line is wrong; nothing was opened or calledstderr: what is wrong, and the command's usage line
125LayerFS refused or failed the call, including the SDK's own input checksstderr: { code, message, detail }, the LayerFSError as the SDK gave it
the command'sworkspace exec --stream only; 125 still means LayerFS failedThe command's own output
  • Same call, same answer. Tests run every call both ways, through the SDK and through layerfs, and require the same binding call and the same answer: over a stand-in binding, and against a real Store, Docker and daemon.
  • Each command starts again. It opens the project and closes it, so a command pays Node's startup and the open each time; this hasn't been measured. For a loop of many calls, write an SDK script.
  • Agent calls only. @layerfs/runtime and @layerfs/backend serve integration hosts and have no commands; the runtime's older lfs command is retired.

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/agent";

try {
  await layerfs.workspace.unmount(ws);
} 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
BusyThe project's lock was held for 60 s, or a background process still runs in the WorkspaceStop and report, or stop the process first
CapacityThe Sandbox already holds 16 Workspacessandbox.create another and mount there, or unmount ones you're done with
NoSuchPath, NoSuchBranch, NoSuchCommit, NoSuchProject, NoSuchSandboxThe thing named doesn't existList it: fs.list, project.list_branches, project.list_sandboxes
NoMatch, AmbiguousMatchfs.edit found zero or several matchesRead the file again; widen oldText
IsDirectory, NotDirectory, Binary, NotTextWrong kind of path or contentUse a Workspace 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 Workspace not mountedUnmount first; use the Workspace from mount
TooLargeA Workspace call's request didn't fit one daemon control record; nothing sentA smaller call, or one Workspace step
UnsupportedStore calls where they can't runRun on Linux with a local Store, or name the LayerFS home's worker with LAYERFS_WORKER_ENDPOINT
InvalidInputA bad argument, a missing setting, or a closed clientRead message
Unknown, FailedThe outcome is uncertain or failedStop, check, report

Results

  • Every result has a kind: Initialized, 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, nextPagePos }; ask for the next at nextPagePos.