diff --git a/docs.json b/docs.json
index 7e72b01..dc4aa4a 100644
--- a/docs.json
+++ b/docs.json
@@ -92,7 +92,8 @@
"guides/dev/session-keys",
"guides/dev/smart-accounts",
"guides/dev/authz",
- "guides/dev/authz-custom"
+ "guides/dev/authz-custom",
+ "guides/dev/vfs-hidden-files"
]
},
{
diff --git a/guides/dev/vfs-hidden-files.mdx b/guides/dev/vfs-hidden-files.mdx
new file mode 100644
index 0000000..e766120
--- /dev/null
+++ b/guides/dev/vfs-hidden-files.mdx
@@ -0,0 +1,168 @@
+---
+title: 'VFS hidden files and folders'
+description: 'Keep sensitive files out of listings, reads, and search in the IXO Virtual Filesystem, and reveal them selectively with the nb.hidden UCAN caveat.'
+---
+
+The IXO Virtual Filesystem (VFS) supports **hidden** files and folders so a domain can keep sensitive content — `.env`, `.config/…`, private reports — out of normal listings, reads, and search. Hidden items are reachable only by a token that explicitly asks for them via an `nb.hidden` UCAN caveat. This is a **policy layer on top of** the existing namespace, path scope, and UCAN model — not a separate permission system.
+
+
+`.env` is a perfectly valid filename. It just isn't a *visible* one by default.
+
+
+## When to use it
+
+- Store per-domain secrets or configuration alongside user content without leaking them through generic list, search, or agent tools.
+- Ship VFS-backed AI agents that only ever see the non-sensitive subset of a user's tree, unless the caller mints a token that explicitly opts them in.
+- Give the domain owner (or their client) a token with reveal-everything so they can manage their own hidden content, while every delegated agent stays blind by default.
+
+## Concept
+
+- A file or folder whose **own name starts with `.`** is **hidden by default** — the Unix dotfile convention. This default is overridable per item.
+- A **hidden folder hides its entire subtree.** An item is *effectively hidden* when it, or any ancestor folder, is hidden.
+- Hidden items are **invisible and unreadable** on every read surface **unless** the caller's UCAN carries an `nb.hidden` reveal caveat that covers them.
+- **Safe by default:** with no reveal caveat, nothing hidden leaks — not even to a whole-tree owner token. Owner clients mint tokens with `nb.hidden: ["*"]` so the owner sees their own hidden content.
+
+## What "hidden" means on every surface
+
+Without a matching reveal caveat, an effectively-hidden item is filtered from every read surface — REST and MCP alike:
+
+| Surface | Behaviour |
+| --- | --- |
+| `GET /api/fs/files` (list), `GET /api/fs/tree`, `GET /api/fs/glob` | Excluded from results |
+| `GET /api/fs/grep`, `GET /api/fs/search` (lexical **and semantic**) | Never matched; content never returned in snippets |
+| `GET /api/fs/files/:id`, `/content`, `/read`, `/events`, `/provenance` | `404` — existence is never confirmed |
+| `GET /api/fs/cid/:cid`, `GET /api/fs/cids` | Not listed; fetch-by-CID returns `404` |
+| `GET /api/fs/trash` | Excluded |
+| MCP tools (all 13, including `vfs_list`, `vfs_grep`, `vfs_search`, `vfs_read`, `vfs_read_cid`) | Same as above |
+| Batch operations across any of the above | Same as above |
+
+Hiddenness never affects **writes by explicit id or path.** You can create, un-hide, or move an item you can address directly — even one you can't yet list.
+
+## Set or unset the hidden flag
+
+Every item can be forced hidden or visible regardless of its name. The dot-prefix is only the **default**. All calls require `Authorization: Bearer ` with `fs/write` capability and `X-Auth-Type: ucan`.
+
+
+Setting the flag is **always an explicit set, never a toggle.** You must supply the value; omitting it returns `400`.
+
+
+### On upload — `?hidden=true|false`
+
+```bash
+# a dotfile kept VISIBLE
+curl -X POST "$VFS/api/fs/files?path=/.env&hidden=false" \
+ -H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan" \
+ -H "Content-Type: text/plain" --data-binary 'PORT=3000'
+# → 201 { "id": "…", "path": "/.env", "hidden": false, … }
+
+# a NORMAL file made hidden from birth
+curl -X POST "$VFS/api/fs/files?path=/report.pdf&hidden=true" \
+ -H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan" \
+ -H "Content-Type: application/pdf" --data-binary @report.pdf
+```
+
+Omit `?hidden=` to fall back to the dotfile convention (`.env` → hidden, `report.pdf` → visible).
+
+### Change a file's flag afterwards — `PATCH /api/fs/files/:id/hidden`
+
+The value comes from `?hidden=true|false` (preferred) **or** a JSON body `{ "hidden": }`:
+
+```bash
+# un-hide via query param
+curl -X PATCH "$VFS/api/fs/files//hidden?hidden=false" \
+ -H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan"
+
+# hide via JSON body (equivalent)
+curl -X PATCH "$VFS/api/fs/files//hidden" \
+ -H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan" \
+ -H "Content-Type: application/json" -d '{ "hidden": true }'
+# → 200 { …, "hidden": true }
+```
+
+You get `` from the upload response. To *discover* the id of an already-hidden file via `GET /api/fs/files`, the token needs a reveal caveat — but flipping the flag itself needs no reveal, since you supply the id directly.
+
+### Change a folder's flag — `PUT /api/fs/folders/hidden`
+
+Sets the folder's own flag and **cascades effective-hidden through its whole subtree.** Value comes from `?hidden=true|false` or a JSON body.
+
+```bash
+# hide a normal folder (hides everything under /reports)
+curl -X PUT "$VFS/api/fs/folders/hidden?path=/reports&hidden=true" \
+ -H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan"
+# → 200 { "path": "/reports", "hidden": true, "hiddenEffective": true }
+
+# un-hide a dot-folder — reveals its non-dotted descendants;
+# self-dotted children stay hidden
+curl -X PUT "$VFS/api/fs/folders/hidden?path=/.config&hidden=false" \
+ -H "Authorization: Bearer $BEARER" -H "X-Auth-Type: ucan"
+```
+
+Folders are first-class in the data model, so a dotted folder can be un-hidden as a unit and its non-dotted descendants correctly reappear while any self-dotted children stay hidden.
+
+### Quick reference
+
+| Goal | Endpoint | Value |
+| --- | --- | --- |
+| Set at upload | `POST /api/fs/files?path=…&hidden=true\|false` | Query param |
+| Set a file later | `PATCH /api/fs/files/:id/hidden` | `?hidden=` **or** JSON `{ "hidden": bool }` |
+| Set a folder (cascades) | `PUT /api/fs/folders/hidden?path=…` | `?hidden=` **or** JSON `{ "hidden": bool }` |
+
+`GET /api/fs/files/:id` returns a `hidden` boolean on file metadata — the *effective* state, so it accounts for any hidden ancestor folder.
+
+## From an agent — MCP
+
+Agents get the same behaviour. Every read, list, and search tool honours the session token's reveal set, and a dedicated tool sets the flag on either a file or a folder:
+
+```
+vfs_set_hidden { path: "/secrets", hidden: true } // file OR folder; folders cascade
+```
+
+A CID-scoped MCP session still needs an `nb.hidden` entry to reach hidden content by CID.
+
+## Reveal hidden items — the `nb.hidden` UCAN caveat
+
+A hidden item becomes visible to a token whose UCAN capability carries an `nb.hidden` caveat that covers it. The caveat lives on the capability, alongside `with` and `can`:
+
+```jsonc
+{
+ "can": "fs/read",
+ "with": "ixo:filesystem",
+ "nb": {
+ "hidden": ["*"] // reveal EVERYTHING hidden in scope
+ // or a specific set:
+ // "hidden": ["/secrets/.env", "/reports"] // paths reveal that item + its subtree
+ // "hidden": ["bafkrei…"] // a CID reveals matching files
+ }
+}
+```
+
+### Reveal entry forms
+
+- **`*`** — reveal all hidden items in scope. Owner clients typically mint this for the owner's own tokens.
+- **A path** (`/secrets/.env`, `/reports`) — reveal that item **and its subtree.**
+- **A CID** (`bafkrei…`) — reveal files whose content id matches. Use with `GET /api/fs/cid/:cid` or `vfs_read_cid`.
+
+### Attenuation
+
+Like `nb.cids`, the reveal set only narrows down a delegation chain — a delegate can never widen what an ancestor granted. `*` is the *universal* set, so the effective reveal is the intersection of the specific (non-`*`) sets down the chain:
+
+```
+owner mints nb.hidden: ["*"] → delegate nb.hidden: ["/reports"] ⇒ effective ["/reports"]
+owner mints nb.hidden: ["/reports"] → delegate nb.hidden: ["/reports", "/secrets"] ⇒ effective ["/reports"]
+```
+
+### Composition with other scopes
+
+`nb.hidden` only lifts the hidden filter. It **composes with, never overrides,** the other UCAN scopes — the most restrictive wins:
+
+- **Namespace isolation** still applies: a reveal-all token cannot see another user's namespace.
+- **Path scope** still applies: `nb.hidden: ["*"]` on a token scoped to `/reports` reveals only hidden items under `/reports`.
+- **CID scope** still applies: a CID-scoped token still needs a CID or `*` in `nb.hidden` to fetch matching hidden files.
+
+## Security properties
+
+- **Default-deny.** No `nb.hidden` caveat means nothing hidden is visible or readable, on any surface, for any token — including a whole-tree `*` owner token.
+- **No cross-surface leak.** The same reveal check gates listings, grep, semantic search (vector candidates are re-validated against the metadata store with the hidden filter, so hidden content never escapes via embeddings), CID lookup, trash, metadata, and content.
+- **Attenuation only.** A delegate can never widen the reveal set an ancestor granted.
+- **Composes, never bypasses.** Reveal lifts only the hidden filter; namespace isolation, path scope, and CID scope still apply.
+- **Bounded.** Reveal sets are capped, and the folder-toggle cascade is bounded by the subtree size.