Filesystem API
Two endpoints serve clients that mirror a whole workspace. The manifest lists every file with its content hash and is cheap to poll. The batch endpoint applies many ordered operations as one changeset. The CLI's local-folder sync and the desktop app both use them.
GET /api/fs/manifest
Returns every non-directory file in the workspace with its content hash, sorted by path. It reads no file content, so it is cheap even on a large workspace.
Requires files:read.
curl -s "https://www.monofile.ai/api/fs/manifest" \
-H "Authorization: Bearer $MONOFILE_API_KEY"{
"etag": "6f1c4b2a9d0e7f3b8c5a1e4d2b7f9c0a3e6d8b1f4c7a2e5d9b0f3c6a8e1d4b7f",
"generatedAt": "2026-09-17T10:04:11.229Z",
"count": 3,
"entries": [
{
"path": "/Projects/README.md",
"fileId": "clx9a1b2c3d4e5f6",
"contentHash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
"size": 1284,
"mimeType": "text/markdown",
"updatedAt": "2026-09-16T18:22:03.117Z",
"hasMedia": false
},
{
"path": "/Projects/diagram.draw",
"fileId": "clx7f6e5d4c3b2a1",
"contentHash": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9",
"size": 8801,
"mimeType": "application/json",
"updatedAt": "2026-09-15T09:41:55.004Z",
"hasMedia": false
},
{
"path": "/Photos/cover.png",
"fileId": "clx1a2b3c4d5e6f7",
"contentHash": null,
"size": 481920,
"mimeType": "image/png",
"updatedAt": "2026-09-10T12:00:00.000Z",
"hasMedia": true
}
]
}What a row means
| Field | Meaning |
|---|---|
path | Workspace-absolute path, always starting with / |
fileId | Stable id. It survives renames and moves, so track files by it. |
contentHash | SHA-256 of the text content, as lowercase hex. null for media files. |
size | Bytes |
mimeType | Detected type |
updatedAt | ISO 8601 timestamp of the last change |
hasMedia | true for media, whose bytes live in object storage. Download those through the files API; they have no text hash to compare. |
Polling cheaply
The response carries ETag: "<etag>" and Cache-Control: private, no-cache. Send the value back on the next poll and an unchanged workspace answers 304 Not Modified with an empty body:
If-None-Match: "6f1c4b2a9d0e7f3b8c5a1e4d2b7f9c0a3e6d8b1f4c7a2e5d9b0f3c6a8e1d4b7f"curl -s -o /dev/null -w "%{http_code}\n" "https://www.monofile.ai/api/fs/manifest" \
-H "Authorization: Bearer $MONOFILE_API_KEY" \
-H 'If-None-Match: "6f1c4b2a9d0e7f3b8c5a1e4d2b7f9c0a3e6d8b1f4c7a2e5d9b0f3c6a8e1d4b7f"'304The ETag covers the whole manifest, so any create, edit, rename or delete changes it. If-None-Match accepts strong and weak forms, comma-separated lists and *. Poll on an interval, and read the body only when the status is 200.
A file named /manifest
manifest is a route on /api/fs, so a file named exactly /manifest at the workspace root is not readable at that URL. Read it through the files API.
POST /api/fs/batch
Applies up to 500 operations in the order given, through the same write path the editor uses, so versions, wiki-link indexing and media cleanup all still happen.
Requires files:write for any write, rename, mkdir or link operation, and files:delete if any operation is a delete. The scope check runs before any operation is applied. A missing scope returns 403 with INSUFFICIENT_SCOPE and nothing in the batch lands.
Request
{
"ops": [
{ "op": "mkdir", "path": "/Notes/2026" },
{ "op": "write", "path": "/Notes/2026/week-38.md", "content": "# Week 38\n", "ifHash": null },
{ "op": "write", "path": "/Notes/README.md", "content": "# Notes\n\n- week-38\n",
"ifHash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" },
{ "op": "rename", "from": "/Notes/old.md", "to": "/Notes/2026/old.md" },
{ "op": "delete", "path": "/Notes/scratch.md" }
],
"onConflict": "overwrite",
"stopOnError": false,
"kind": "sync"
}curl -s -X POST "https://www.monofile.ai/api/fs/batch" \
-H "Authorization: Bearer $MONOFILE_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Monofile-Run-Id: local-sync-8814" \
-d '{
"ops": [
{ "op": "mkdir", "path": "/Notes/2026" },
{ "op": "write", "path": "/Notes/2026/week-38.md", "content": "# Week 38\n", "ifHash": null },
{ "op": "write", "path": "/Notes/README.md", "content": "# Notes\n\n- week-38\n",
"ifHash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" },
{ "op": "rename", "from": "/Notes/old.md", "to": "/Notes/2026/old.md" },
{ "op": "delete", "path": "/Notes/scratch.md" }
],
"onConflict": "overwrite",
"stopOnError": false,
"kind": "sync"
}'| Body field | Default | Meaning |
|---|---|---|
ops | required | 1 to 500 operations, applied in order |
onConflict | overwrite | What to do when an ifHash precondition fails |
stopOnError | false | Abandon the rest of the batch after the first failed operation |
kind | fs | Changeset label: fs for a plain client push, sync for a local-folder sync round |
The operations
| Operation | Fields | Notes |
|---|---|---|
write | path, content, ifHash?, createParents? | Creates or overwrites. createParents defaults to true. Maximum 5 MB of text. |
delete | path, ifHash?, recursive? | recursive defaults to false. A missing path is not an error. |
rename | from, to, ifHash? | Keeps the file id, so wiki-links, share links and version history survive |
mkdir | path, recursive? | recursive defaults to true. Takes no precondition. |
link | path, key, size, mimeType, ifHash? | Attaches a media object already uploaded through the presigned upload flow. The key must belong to your own media prefix, or the operation fails with INVALID_KEY. |
Preconditions and conflicts
ifHash is a precondition on the path's current text hash, the same value the manifest gave you. Pass null to assert the path is absent.
When the hash does not match, somebody changed the file since you last looked. The response reports a conflict, and by default the operation is applied anyway. The content you overwrote goes into the file's version history. Send onConflict: "skip" to leave the path untouched instead; the operation then comes back with status: "skipped" and its conflict recorded.
Compare hashes, not timestamps. A clock can drift and a save can leave the content identical, but the hash changes only when the bytes do.
Response
{
"results": [
{ "index": 0, "op": "mkdir", "path": "/Notes/2026", "status": "ok", "fileId": "clx0011223344aabb" },
{ "index": 1, "op": "write", "path": "/Notes/2026/week-38.md", "status": "ok",
"hash": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c",
"fileId": "clx5566778899ccdd" },
{ "index": 2, "op": "write", "path": "/Notes/README.md", "status": "ok",
"hash": "7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730",
"fileId": "clxaabbccddeeff00",
"conflict": { "expectedHash": "2c26b46b...e7ae", "actualHash": "9f86d081...0865" } },
{ "index": 3, "op": "rename", "path": "/Notes/2026/old.md", "status": "ok", "fileId": "clx1122334455667" },
{ "index": 4, "op": "delete", "path": "/Notes/scratch.md", "status": "ok" }
],
"conflicts": [
{ "index": 2, "path": "/Notes/README.md",
"expectedHash": "2c26b46b...e7ae", "actualHash": "9f86d081...0865", "applied": true }
],
"applied": 5,
"failed": 0,
"skipped": 0,
"changesetId": "clx8c1f0e4b7a92dd"
}Four details of the response:
results[i].statusis one ofok,skippedorerror, andapplied,failedandskippedare the counts of each.A
renamereports its destination inresult.path.With
stopOnError: truethe remaining operations come back asskippedwith codeABORTED.One changeset per batch.
changesetIdnames it, and it isnullwhen the batch wrote nothing. Reverting that changeset undoes the whole batch in one action. If you sentX-Monofile-Run-Id, the batch joins that run's changeset instead of opening its own.
Limits
| Limit | Value |
|---|---|
| Operations per batch | 500 |
Text per write | 5 MB |
| Path length | 4096 characters |
Media does not go through write. Upload the bytes with the presigned upload flow, then attach them with a link operation.
Next steps
Local FoldersClone and sync: keep a real directory in step with your workspace.
File and Shell ToolsReading, writing and editing files, plus the two shell tiers.
ChangesetsRuns grouped into one reviewable, revertable unit.
Errors and LimitsEvery error code, what causes it, and how to recover.