# Agent filesystem operations

The ordinary user API now has one set of bounded filesystem operations shared by REST, remote MCP and stdio MCP. They call the existing file service, permissions, immutable versions, scanning, storage and quotas. No new schema or hosted execution service is needed. Deployment status is recorded in [DEPLOYMENT.md](DEPLOYMENT.md).

## References

Every new operation takes a `reference`, or a list of `references`. Supported forms:

- Existing file/folder ID, `stuff:item:ID`, or `stuff:space:SPACE_ID` (the space root).
- A file/folder URL from the configured Stuff origin. Public URLs can identify items, but never add their public permissions to a connection.
- `stuff://SPACE_ID/Notes/Plan.md`, with URL-encoded path components.
- `/Notes/Plan.md` or `Plan.md` with an explicit `space` (exact accessible name or ID) or `base` (folder reference). A leading slash is relative to this chosen base. `..` traversal is rejected; no implicit working directory is shared between agents.

Ambiguous space names return `ambiguous_reference`; use the ID. Existing MCP item/parent fields also normalize typed references and Stuff URLs. Classic REST item routes remain ID-based; use `/fs/resolve` before constructing them. File/folder/space copy controls are available in the signed-in UI; see [agent workflows](agent-workflows.md). User references, symlinks and filesystem mounts are not implemented.

## API and MCP

REST: `POST /api/v1/fs/OPERATION` with a JSON body and normal API-key authentication. Remote/stdio MCP: `fs_OPERATION` with the same fields; OAuth works through MCP as before. OpenAPI schemas and both MCP schema sets come from `app/shared/filesystem.ts`.

| Operation   | Main input                                                             | Result                                                                                     |
| ----------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `resolve`   | `reference`, optional `base` or `space`                                | Stable ID/reference, URL, authorized path, version, role-derived capabilities              |
| `list`      | reference, recursive, glob, cursor, limit (1–200)                      | Compact entries, relative paths, `nextCursor`                                              |
| `read`      | reference, optional version, startLine, startChar, lineCount, maxChars | Original text slice, version/hash, line count, continuation                                |
| `read_many` | references (1–10), context and read limits                             | Per-file success/error; shared 262,144-character output budget                             |
| `grep`      | reference, query, glob, caseSensitive, contextLines, limit, cursor     | Literal matches with line/column/context, source version/hash, skipped files, continuation |
| `edit`      | reference, version, edits, dryRun, idempotency_key                     | Change summary, new hash/version; no write during preview                                  |
| `batch`     | actions (1–20), context, dryRun, idempotency_key                       | Advisory preview or atomic mutation receipt                                                |

Listing supports `*`, `?`, `**`; a pattern without `/` matches basenames, so `*.md` works recursively. Other shell expansion syntax is not supported. Pages are ordered by stable ID, not path. Follow `nextCursor` even when a filtered page is empty. Pages are current views, not a locked tree snapshot.

### Ranged reads and exact search

Text originals are limited to 5 MiB; binary/NUL/non-UTF-8 originals return explicit errors. Original BOM and CR/LF terminators are retained. Lines are 1-based. `startChar` is a zero-based UTF-16 offset within the starting line, and `maxChars` is measured in UTF-16 code units (2–131,072). Defaults are 200 lines / 32,768 characters. Continue with the returned `next.startLine` and `next.startChar`; retain the version and stop/reconcile if it changes. Historical reads retain the existing Editor-role requirement.

`read_many` returns independent outcomes and marks incomplete results; a successful call does not imply every file was read completely. Text and metadata remain untrusted file content.

`grep` searches exact original text beyond the existing indexed-search prefix. It is literal, case-sensitive by default, and does not execute regex. Optional case folding rejects Unicode expansions that would make offsets incorrect. Each page scans at most 20 tree entries and a 10 MiB content budget, with at most 100 matches. Context is clipped to bounded excerpts. Large/binary/pending/quarantined originals appear in `skipped`; retain skips from every page. PDF/Office extraction, image OCR and media transcription are not included. This is separate from `/search`, which retains indexed text/Office behavior.

Search cursors bind the query/root/glob/case mode. A continuation inside a changed file returns `search_version_conflict`; restart. Across files, concurrent tree changes can affect later pages. A final page's `complete` applies to that page; whole-search completeness requires no skips across all pages.

### Guarded text edits

Edits are ordered and checked against the required base version:

```json
{
  "reference": "stuff:item:FILE_ID",
  "version": 4,
  "idempotency_key": "one-intended-edit-20260926",
  "edits": [
    {
      "type": "replace",
      "oldText": "Old sentence.",
      "newText": "New sentence.",
      "expectedMatches": 1
    },
    { "type": "append", "text": "\nA new paragraph.\n" }
  ]
}
```

`lines` edits take inclusive `startLine`/`endLine`, exact `oldText` including line terminators, and `newText`. This is a structured patch format, not a unified-diff parser. All checks finish before a single new version is saved. An unchanged edit creates no new version. `dryRun:true` computes hashes/size/change status without saving. Writes require a stable retry key; replay returns the original result rather than appending twice. Never refresh a stale base version merely to force an overwrite.

### Batches

Supported actions: `mkdir`, `create` (UTF-8 file), `edit`, `rename`, `move`, `trash`. Destinations must exist; there are no references to earlier results. Split dependent creates into deliberate requests. Copy and permanent deletion remain separate existing APIs.

Preview defaults to `dryRun:true` and checks accessible targets, basic permissions and edit preconditions. It reports cross-space audience changes and subtree counts. It is advisory, not a quota/name reservation or an exact simulation of all subsequent actions. Apply uses one transaction and one stable key, revalidates all operations, and returns `failedIndex`, `atomic:true`, `applied:0` on failure. New names must be unused. Cross-space moves still require `confirmAccess:true`; no action silently publishes content. Maximum request body remains 2 MiB.

Completed receipts are reauthorized before replay. A retry cannot reveal items whose scope/access was removed. Large transfers use resumable uploads, not huge batches.

## CLI

Install the [standalone CLI](cli.md) with Node 24+, then set `STUFF_API_KEY`. It defaults to https://stuff.ac. These source-checkout examples still work; replace `node cli/stuff.mjs` with `stuff` after installing.

```sh
node cli/stuff.mjs ls / --space "Stuff Wiki" --recursive --glob '*.md'
node cli/stuff.mjs resolve 'stuff:space:SPACE_ID'
node cli/stuff.mjs read FILE_URL --lines 1:80
node cli/stuff.mjs read-many FILE_ID OTHER_FILE_ID
node cli/stuff.mjs grep 'pricing' FOLDER_ID --glob '*.md'
node cli/stuff.mjs edit FILE_ID --version 4 --patch edits.json
node cli/stuff.mjs edit FILE_ID --version 4 --patch edits.json --apply --idempotency-key one-intended-edit
node cli/stuff.mjs batch actions.json
node cli/stuff.mjs batch actions.json --apply --idempotency-key one-intended-batch
node cli/stuff.mjs pull-folder FOLDER_ID ./new-checkout
node cli/stuff.mjs pull-folder FOLDER_ID ./new-checkout --resume
node cli/stuff.mjs push-folder ./new-checkout
node cli/stuff.mjs push-folder ./new-checkout --apply
```

`edits.json` contains an array of edit operations. `actions.json` contains an array of batch actions. CLI edit/batch/push default to preview; explicit apply is required. Read/list/search commands expose cursors rather than silently discarding later results. CLI MCP exposes the same seven named filesystem tools, not only a generic endpoint escape hatch.

## Folder pull/push semantics

Pull requires a new local directory and writes original bytes plus a private `.stuff-sync.json` journal containing origin, stable IDs, relative paths, base versions and hashes. It preserves binary originals and empty folders. No credentials are stored. Keep the journal; it is what makes conflict-aware push possible. Checkouts are limited to 10,000 entries, and per-file upload limits still apply.

`--resume` recovers interrupted pulls without overwriting edited local files. Changed source versions stop the pull rather than silently mixing newer bytes into the recorded baseline. Listing is not a transactional snapshot of the entire tree; for a consistent export, pause edits or use a fresh checkout after concurrent changes.

Push first compares local contents and remote IDs/paths/versions. It plans changed-file versions, new files and new directories. Remote content conflicts, moves or renames stop the plan before applying it. A local deletion retains the remote item; a local rename is an addition plus a retained old remote file, not an inferred move. Use explicit move/trash APIs for those actions. It does not sync custom metadata, descriptions, permissions or history archives.

Push is a journaled sequence, not an atomic tree transaction. Completed writes remain on interruption; rerun to resume. Lost responses are recovered through create receipts or matching latest bytes after a versioned push. A conflict during apply preserves earlier completed work and the local draft. New-name collisions do not overwrite unrelated files. Symlinks, path traversal, reserved journal names and local case/Unicode-normalization collisions on pull are rejected. Local rate limits are handled with bounded retry; processing failures remain explicit.

## Errors and remaining work

REST and MCP now preserve `code`, `retryable`, `nextAction` and available details such as expected/current version or batch failure index. Schema errors identify fields. Rate limits include retry guidance; content unavailable is not automatically classified as retryable because quarantine/failure is different from pending checks. CLI stdio preserves server error payloads. SDK-level protocol/schema failures may use the MCP SDK's own envelope.

Deferred: filesystem mounts, remote shell execution, watchers/background two-way sync, automatic merge/rename/delete inference, arbitrary regex, complete binary extraction, unified-diff parsing, user references, packaged folder download in the browser and browser bulk-action UI. These are independent follow-ups, not implied by this release.
