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.

> [!NOTE]
> If you are editing a handful of files, use the tools in [[File and Shell Tools]] instead. These endpoints exist for sync clients.

## 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`.

```http
GET /api/fs/manifest
```

```bash title="Terminal"
curl -s "https://www.monofile.ai/api/fs/manifest" \
  -H "Authorization: Bearer $MONOFILE_API_KEY"
```

```json nocopy
{
  "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:

```http
GET /api/fs/manifest
If-None-Match: "6f1c4b2a9d0e7f3b8c5a1e4d2b7f9c0a3e6d8b1f4c7a2e5d9b0f3c6a8e1d4b7f"
```

```bash title="Terminal"
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"'
```

```text nocopy
304
```

The 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`.

:::details 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

```http tab="HTTP"
POST /api/fs/batch
{
  "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"
}
```

```bash tab="curl" title="Terminal"
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

```json nocopy
{
  "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"
}
```

> [!WARNING]
> **A per-operation failure does not fail the request.** The status is `200`, and the failing entry carries `status: "error"` with a filesystem code such as `ENOENT`, `EISDIR`, `ENOTDIR`, `EEXIST` or `ENOTEMPTY`. Walk `results` and reconcile.

Four details of the response:

- **`results[i].status` is one of `ok`, `skipped` or `error`**, and `applied`, `failed` and `skipped` are the counts of each.
- **A `rename` reports its destination** in `result.path`.
- **With `stopOnError: true`** the remaining operations come back as `skipped` with code `ABORTED`.
- **One changeset per batch.** `changesetId` names it, and it is `null` when the batch wrote nothing. Reverting that changeset undoes the whole batch in one action. If you sent `X-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

:::cards
- [[Local Folders]] — Clone and sync: keep a real directory in step with your workspace.
- [[File and Shell Tools]] — Reading, writing and editing files, plus the two shell tiers.
- [[Changesets]] — Runs grouped into one reviewable, revertable unit.
- [[Errors and Limits]] — Every error code, what causes it, and how to recover.
:::
